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
+1496
-318
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())))
|
||||
})
|
||||
}
|
||||
@@ -298,6 +298,27 @@ A: Calibre Web uses the same `metadata.opf` files, so yes - Bookhoard can scan a
|
||||
|
||||
A: Bookhoard has its own sorting logic and doesn't import Calibre sort fields. This allows for consistent sorting across all books.
|
||||
|
||||
## Writing metadata back to files (admin)
|
||||
|
||||
The book page's metadata editor has a **Write to file** action alongside
|
||||
Save. Save updates Bookhoard's database only; Write to file persists the
|
||||
currently saved values into the artifact on disk, then re-extracts so
|
||||
everything stays in sync:
|
||||
|
||||
- **EPUB/KEPUB** — the OPF embedded in the book is rewritten in place
|
||||
(cover, chapters and reading position are untouched). The file's
|
||||
checksum changes, so device syncs (Kobo, KOReader) re-key the book on
|
||||
their next sync.
|
||||
- **Everything else** (PDF, comics, folder books, audio until tag
|
||||
writing ships) — a Calibre-style sidecar is written next to the file:
|
||||
`metadata.opf` when the book is alone in its folder (Calibre's own
|
||||
convention, so Calibre reads it too), or `<filename>.opf` when several
|
||||
books share a folder, so one book's sidecar can never leak onto its
|
||||
neighbours.
|
||||
|
||||
The write mirrors exactly what the scanner reads back, and the original
|
||||
file is never touched if anything fails (temp file + atomic rename).
|
||||
|
||||
## Resources
|
||||
|
||||
- [Calibre Website](https://calibre-ebook.com/)
|
||||
|
||||
@@ -53,6 +53,11 @@ type Querier interface {
|
||||
// collections page); same live filter as GetCollectionItems, plus the
|
||||
// future-hybrid excluded exclusion and an optional library scope.
|
||||
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
|
||||
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
||||
// metadata.opf may only be written when NO other non-archived item
|
||||
// shares the directory; otherwise the sidecar goes to
|
||||
// <basename>.opf so one book's metadata can never poison dir-mates.
|
||||
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error)
|
||||
// Count unlinked books for a device (ignored rows stay hidden)
|
||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||
// Banner count: unresolved, non-ignored queue rows across the caller's own
|
||||
|
||||
@@ -369,6 +369,32 @@ func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollect
|
||||
return column_1, err
|
||||
}
|
||||
|
||||
const CountOtherMediaItemsInDir = `-- name: CountOtherMediaItemsInDir :one
|
||||
SELECT COUNT(*)::int
|
||||
FROM media_items
|
||||
WHERE library_id = $1::uuid
|
||||
AND id != $2::uuid
|
||||
AND archived_at IS NULL
|
||||
AND file_path LIKE ($3::text || '%')
|
||||
`
|
||||
|
||||
type CountOtherMediaItemsInDirParams struct {
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
DirPrefix pgtype.Text `db:"dir_prefix" json:"dir_prefix"`
|
||||
}
|
||||
|
||||
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
||||
// metadata.opf may only be written when NO other non-archived item
|
||||
// shares the directory; otherwise the sidecar goes to
|
||||
// <basename>.opf so one book's metadata can never poison dir-mates.
|
||||
func (q *Queries) CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, CountOtherMediaItemsInDir, arg.LibraryID, arg.ID, arg.DirPrefix)
|
||||
var column_1 int32
|
||||
err := row.Scan(&column_1)
|
||||
return column_1, err
|
||||
}
|
||||
|
||||
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
|
||||
SELECT COUNT(*) as count
|
||||
FROM unlinked_books
|
||||
|
||||
@@ -4166,6 +4166,18 @@ SELECT COUNT(*)::int FROM (
|
||||
)
|
||||
) groups;
|
||||
|
||||
-- name: CountOtherMediaItemsInDir :one
|
||||
-- Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
|
||||
-- metadata.opf may only be written when NO other non-archived item
|
||||
-- shares the directory; otherwise the sidecar goes to
|
||||
-- <basename>.opf so one book's metadata can never poison dir-mates.
|
||||
SELECT COUNT(*)::int
|
||||
FROM media_items
|
||||
WHERE library_id = sqlc.narg('library_id')::uuid
|
||||
AND id != sqlc.narg('id')::uuid
|
||||
AND archived_at IS NULL
|
||||
AND file_path LIKE (sqlc.narg('dir_prefix')::text || '%');
|
||||
|
||||
-- name: GetSeriesCovers :many
|
||||
SELECT cover_image_path, library_id
|
||||
FROM media_items
|
||||
|
||||
@@ -1801,6 +1801,57 @@ func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// WriteMetadataToFile handles POST /api/media-items/:id/write-metadata
|
||||
// (admin only). Persists the item's CURRENT database metadata into the
|
||||
// artifact on disk — embedded OPF for EPUBs, a Calibre-style sidecar
|
||||
// for formats that can't carry metadata (2026-10-27 feature) — then
|
||||
// re-extracts from the file so the scan-derived columns agree with
|
||||
// what was written. metadata_overrides are NOT reset: after the write,
|
||||
// file and DB agree, and the overrides keep stale sidecars from
|
||||
// clobbering future rescans.
|
||||
func (mh *MediaHandler) WriteMetadataToFile(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
if user.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
|
||||
mediaID := c.Param("id")
|
||||
mediaUUID, err := uuid.Parse(mediaID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
|
||||
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
writer := services.NewMetadataWriter(mh.db)
|
||||
result, err := writer.WriteMetadataToFile(c.Request().Context(), mediaUUID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Re-extract so the scan pipeline sees the file we just wrote. A
|
||||
// rescan failure doesn't undo the write — it surfaces separately.
|
||||
rescanStatus := "ok"
|
||||
scanner := services.NewMediaScanner(mh.db)
|
||||
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, false); err != nil {
|
||||
rescanStatus = "error: " + err.Error()
|
||||
}
|
||||
defer scanner.Close()
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"write": result,
|
||||
"rescan": rescanStatus,
|
||||
"metadata_target": string(result.Target),
|
||||
"fields_written": result.FieldsWritten,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
|
||||
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
@@ -74,6 +74,7 @@ func registerMediaRoutes(cfg *Config) {
|
||||
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
||||
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
||||
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
|
||||
admin.POST("/media-items/:id/write-metadata", cfg.MediaHandler.WriteMetadataToFile)
|
||||
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
|
||||
admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
|
||||
admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem)
|
||||
|
||||
@@ -1773,24 +1773,25 @@ func applyTitleDerivedSeries(metadata *MediaMetadata) {
|
||||
|
||||
// extractCalibreSidecar checks for and parses a Calibre metadata.opf sidecar file
|
||||
func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
|
||||
// Get directory of media file
|
||||
// Per-file sidecar FIRST (<basename>.opf, the writer's multi-book
|
||||
// safe name), then Calibre's dir-scoped metadata.opf.
|
||||
dir := filepath.Dir(path)
|
||||
opfPath := filepath.Join(dir, "metadata.opf")
|
||||
|
||||
// Check if sidecar exists
|
||||
if _, err := os.Stat(opfPath); os.IsNotExist(err) {
|
||||
return nil // No sidecar, not an error
|
||||
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
for _, name := range []string{base + ".opf", "metadata.opf"} {
|
||||
opfPath := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(opfPath); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse sidecar
|
||||
metadata, err := s.parseCalibreMetadataOPF(opfPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
|
||||
return nil // Parsing failed, fall back to embedded
|
||||
continue // fall back to the next candidate / embedded
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyEmbeddedCoverFallback extracts a cover from the media file itself when
|
||||
// a metadata sidecar (metadata.opf / metadata.json) supplied the metadata but
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// MetadataWriter persists the item's CURRENT database metadata back into
|
||||
// the artifact on disk, so the library stays portable (Calibre, phone
|
||||
// players, other servers). 2026-10-27 feature (user request): embed into
|
||||
// formats that carry metadata, write a Calibre-style sidecar for those
|
||||
// that don't.
|
||||
//
|
||||
// Safety contract:
|
||||
// - temp file + atomic rename; the original is NEVER truncated on
|
||||
// failure (a failed write must not destroy a book)
|
||||
// - the write mirrors what the scanner's read chain parses
|
||||
// (parseOPFContent), so a write + rescan round-trips
|
||||
// - metadata_overrides are left untouched: after a successful write
|
||||
// the file and the DB agree, and the overrides keep stale sidecars
|
||||
// from clobbering later rescans
|
||||
//
|
||||
// Consequences that are inherent to editing files in place (documented
|
||||
// in docs/user/calibre-integration.md): the mtime change makes the
|
||||
// watcher re-extract (wanted), and the SHA-256 changes (dedup/move
|
||||
// detection re-keys; Kobo device sidecars re-key on next sync).
|
||||
|
||||
// MetadataWriteTarget names where a format's metadata landed.
|
||||
type MetadataWriteTarget string
|
||||
|
||||
const (
|
||||
// MetadataTargetEmbeddedOPF: the OPF inside the EPUB was rewritten.
|
||||
MetadataTargetEmbeddedOPF MetadataWriteTarget = "embedded-opf"
|
||||
// MetadataTargetSidecarOPF: a Calibre-style sidecar was written next
|
||||
// to the file (formats that can't carry metadata: PDF, comics,
|
||||
// folder books; audio until phase 5b).
|
||||
MetadataTargetSidecarOPF MetadataWriteTarget = "sidecar-opf"
|
||||
)
|
||||
|
||||
// MetadataWriteResult reports what the write did, for the UI toast and
|
||||
// the response contract.
|
||||
type MetadataWriteResult struct {
|
||||
Target MetadataWriteTarget `json:"target"`
|
||||
Path string `json:"path"` // absolute path of the artifact written
|
||||
Sidecar bool `json:"sidecar"`
|
||||
FieldsWritten []string `json:"fields_written"`
|
||||
}
|
||||
|
||||
// MetadataWriteTargetFor maps a file extension to the write target.
|
||||
// The UI mirrors this to label the button; keep both in lockstep.
|
||||
func MetadataWriteTargetFor(ext string) MetadataWriteTarget {
|
||||
switch strings.ToLower(strings.TrimSpace(ext)) {
|
||||
case ".epub", ".kepub":
|
||||
return MetadataTargetEmbeddedOPF
|
||||
default:
|
||||
// 5b (audio tags via ffmpeg) and 5c (CBZ ComicInfo.xml) will
|
||||
// claim their formats here; everything else takes the sidecar.
|
||||
return MetadataTargetSidecarOPF
|
||||
}
|
||||
}
|
||||
|
||||
// MetadataWriter carries the dependencies for file-writing operations.
|
||||
type MetadataWriter struct {
|
||||
db *database.Queries
|
||||
libraries *LibraryService
|
||||
}
|
||||
|
||||
func NewMetadataWriter(db *database.Queries) *MetadataWriter {
|
||||
return &MetadataWriter{db: db, libraries: NewLibraryService(db)}
|
||||
}
|
||||
|
||||
// WriteMetadataToFile dispatches on the item's format and persists the
|
||||
// current DB values into the artifact on disk.
|
||||
func (w *MetadataWriter) WriteMetadataToFile(ctx context.Context, itemID uuid.UUID) (*MetadataWriteResult, error) {
|
||||
item, err := w.db.GetMediaItem(ctx, pgtype.UUID{Bytes: itemID, Valid: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("media item not found: %w", err)
|
||||
}
|
||||
|
||||
absPath, err := w.libraries.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot resolve library folder: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(absPath); err != nil {
|
||||
return nil, fmt.Errorf("media file missing on disk: %w", err)
|
||||
}
|
||||
|
||||
switch MetadataWriteTargetFor(filepath.Ext(absPath)) {
|
||||
case MetadataTargetEmbeddedOPF:
|
||||
path, err := w.writeEPUBMetadata(absPath, &item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MetadataWriteResult{
|
||||
Target: MetadataTargetEmbeddedOPF,
|
||||
Path: path,
|
||||
FieldsWritten: metadataFieldNames(&item),
|
||||
}, nil
|
||||
default:
|
||||
path, err := w.writeSidecarOPF(ctx, absPath, &item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MetadataWriteResult{
|
||||
Target: MetadataTargetSidecarOPF,
|
||||
Path: path,
|
||||
Sidecar: true,
|
||||
FieldsWritten: metadataFieldNames(&item),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// writeSidecarOPF writes a Calibre-style OPF next to the media file.
|
||||
// Naming: `metadata.opf` (Calibre's own convention) when the item is
|
||||
// the only book in its directory; otherwise `<basename>.opf` so one
|
||||
// book's sidecar can never poison its dir-mates' scans. The scanner
|
||||
// reads both (basename first).
|
||||
func (w *MetadataWriter) writeSidecarOPF(ctx context.Context, absPath string, item *database.MediaItems) (string, error) {
|
||||
dir := filepath.Dir(absPath)
|
||||
base := strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath))
|
||||
|
||||
others, err := w.countSiblingBooks(ctx, item)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot check sibling books: %w", err)
|
||||
}
|
||||
name := "metadata.opf"
|
||||
if others > 0 {
|
||||
name = base + ".opf"
|
||||
}
|
||||
sidecarPath := filepath.Join(dir, name)
|
||||
|
||||
doc := buildSidecarOPFXML(item)
|
||||
if err := atomicWrite(sidecarPath, []byte(doc)); err != nil {
|
||||
return "", fmt.Errorf("failed to write sidecar: %w", err)
|
||||
}
|
||||
return sidecarPath, nil
|
||||
}
|
||||
|
||||
// countSiblingBooks counts OTHER non-archived items that live in the
|
||||
// same directory of the same library — the guard against writing a
|
||||
// dir-scoped metadata.opf over a multi-book folder.
|
||||
func (w *MetadataWriter) countSiblingBooks(ctx context.Context, item *database.MediaItems) (int, error) {
|
||||
dir := filepath.ToSlash(filepath.Dir(item.FilePath))
|
||||
siblings, err := w.db.CountOtherMediaItemsInDir(ctx, database.CountOtherMediaItemsInDirParams{
|
||||
LibraryID: item.LibraryID,
|
||||
ID: item.ID,
|
||||
DirPrefix: pgtype.Text{String: dir + "/", Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(siblings), nil
|
||||
}
|
||||
|
||||
// writeEPUBMetadata splices a freshly generated <metadata> block into
|
||||
// the EPUB's existing OPF (manifest/spine/guide preserved untouched)
|
||||
// and rewrites the zip atomically: every other entry is copied
|
||||
// byte-identical, in original order with original compression — the
|
||||
// mimetype entry must stay first and STORED.
|
||||
func (w *MetadataWriter) writeEPUBMetadata(absPath string, item *database.MediaItems) (string, error) {
|
||||
reader, err := zip.OpenReader(absPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open EPUB: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
opfName := findOPFPathInZip(reader.File)
|
||||
if opfName == "" {
|
||||
return "", fmt.Errorf("EPUB has no OPF (container.xml missing rootfile)")
|
||||
}
|
||||
|
||||
newOPF, err := spliceOPFMetadata(reader.File, opfName, item)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tmpPath := absPath + ".tmp-write"
|
||||
if err := rewriteEPUBZip(reader.File, opfName, newOPF, tmpPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmpPath, absPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("failed to replace EPUB: %w", err)
|
||||
}
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// spliceOPFMetadata returns the OPF with its <metadata> inner XML
|
||||
// replaced. The ORIGINAL opening tag (with its namespaces and
|
||||
// unique-identifier) is preserved so manifest/spine id references keep
|
||||
// resolving.
|
||||
func spliceOPFMetadata(entries []*zip.File, opfName string, item *database.MediaItems) ([]byte, error) {
|
||||
var opfEntry *zip.File
|
||||
for _, f := range entries {
|
||||
if f.Name == opfName {
|
||||
opfEntry = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if opfEntry == nil {
|
||||
return nil, fmt.Errorf("OPF %q not found in EPUB", opfName)
|
||||
}
|
||||
rc, err := opfEntry.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open OPF: %w", err)
|
||||
}
|
||||
original, err := io.ReadAll(rc)
|
||||
closeErr := rc.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read OPF: %w", err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("failed to close OPF: %w", closeErr)
|
||||
}
|
||||
|
||||
metaStart := bytes.Index(original, []byte("<metadata"))
|
||||
if metaStart == -1 {
|
||||
return nil, fmt.Errorf("OPF has no <metadata> element")
|
||||
}
|
||||
openEnd := bytes.Index(original[metaStart:], []byte(">"))
|
||||
if openEnd == -1 {
|
||||
return nil, fmt.Errorf("OPF <metadata> tag malformed")
|
||||
}
|
||||
metaClose := bytes.Index(original[metaStart:], []byte("</metadata>"))
|
||||
if metaClose == -1 {
|
||||
return nil, fmt.Errorf("OPF has no </metadata> close")
|
||||
}
|
||||
|
||||
openTag := original[metaStart : metaStart+openEnd+1]
|
||||
inner := buildOPFMetadataInner(item) + originalIdentifiers(original[metaStart:metaStart+metaClose])
|
||||
|
||||
out := bytes.NewBuffer(nil)
|
||||
out.Write(original[:metaStart])
|
||||
out.Write(openTag)
|
||||
out.WriteString("\n")
|
||||
out.WriteString(inner)
|
||||
out.Write(original[metaStart+metaClose:])
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// originalIdentifiers extracts the old metadata's dc:identifier
|
||||
// elements verbatim. The package's unique-identifier attribute
|
||||
// references one of them, so replacing the metadata block must keep
|
||||
// them (generated fields come FIRST, so the parser's first-wins reads
|
||||
// the fresh ISBN; the originals remain for identifier stability).
|
||||
func originalIdentifiers(oldMetadata []byte) string {
|
||||
var b strings.Builder
|
||||
rest := oldMetadata
|
||||
for {
|
||||
start := bytes.Index(rest, []byte("<dc:identifier"))
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := bytes.Index(rest[start:], []byte("</dc:identifier>"))
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
end += start + len("</dc:identifier>")
|
||||
b.Write(rest[start:end])
|
||||
b.WriteString("\n")
|
||||
rest = rest[end:]
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// rewriteEPUBZip copies every entry to dst, substituting opfName's
|
||||
// content. Order, compression method and modes are preserved.
|
||||
func rewriteEPUBZip(entries []*zip.File, opfName string, newOPF []byte, dst string) error {
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp EPUB: %w", err)
|
||||
}
|
||||
writer := zip.NewWriter(out)
|
||||
for _, f := range entries {
|
||||
header := f.FileHeader
|
||||
header.Flags &= ^uint16(0x8) // drop data-descriptor flag; sizes are known upfront
|
||||
var target io.Writer
|
||||
if f.Name == opfName {
|
||||
// Sizes/CRC recomputed by the writer for the new content.
|
||||
header.CompressedSize64 = 0
|
||||
header.UncompressedSize64 = 0
|
||||
header.CompressedSize = 0
|
||||
header.UncompressedSize = 0
|
||||
header.CRC32 = 0
|
||||
target, err = writer.CreateHeader(&header)
|
||||
if err != nil {
|
||||
writer.Close()
|
||||
out.Close()
|
||||
return fmt.Errorf("failed to stage OPF entry: %w", err)
|
||||
}
|
||||
if _, err := target.Write(newOPF); err != nil {
|
||||
writer.Close()
|
||||
out.Close()
|
||||
return fmt.Errorf("failed to write OPF entry: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
target, err = writer.CreateHeader(&header)
|
||||
if err != nil {
|
||||
writer.Close()
|
||||
out.Close()
|
||||
return fmt.Errorf("failed to stage entry %s: %w", f.Name, err)
|
||||
}
|
||||
rc, openErr := f.Open()
|
||||
if openErr != nil {
|
||||
writer.Close()
|
||||
out.Close()
|
||||
return fmt.Errorf("failed to read entry %s: %w", f.Name, openErr)
|
||||
}
|
||||
if _, err := io.Copy(target, rc); err != nil {
|
||||
rc.Close()
|
||||
writer.Close()
|
||||
out.Close()
|
||||
return fmt.Errorf("failed to copy entry %s: %w", f.Name, err)
|
||||
}
|
||||
if err := rc.Close(); err != nil {
|
||||
writer.Close()
|
||||
out.Close()
|
||||
return fmt.Errorf("failed to finish entry %s: %w", f.Name, err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
out.Close()
|
||||
return fmt.Errorf("failed to finalize EPUB: %w", err)
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
return fmt.Errorf("failed to sync EPUB: %w", err)
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// atomicWrite writes via temp file + rename in the SAME directory so a
|
||||
// crash or a full disk can never leave a truncated book (or sidecar)
|
||||
// behind.
|
||||
func atomicWrite(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".bookhoard-write-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- OPF generation (mirrors parseOPFContent's read set) ----
|
||||
|
||||
// escXML escapes the five XML entities for element and attribute text.
|
||||
func escXML(s string) string {
|
||||
return strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
).Replace(s)
|
||||
}
|
||||
|
||||
// buildSidecarOPFXML renders a complete Calibre-compatible package
|
||||
// document for a sidecar.
|
||||
func buildSidecarOPFXML(item *database.MediaItems) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(xml.Header)
|
||||
b.WriteString(`<package version="2.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookhoard-uuid">` + "\n")
|
||||
b.WriteString(`<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">` + "\n")
|
||||
b.WriteString(buildOPFMetadataInner(item))
|
||||
b.WriteString("</metadata>\n</package>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildOPFMetadataInner renders the dc:* elements for the item's
|
||||
// current values — exactly the fields parseOPFContent reads back
|
||||
// (title/subtitle via title-type, creators, subjects, description,
|
||||
// publisher, date, language, isbn identifier, calibre:series metas).
|
||||
// Empty DB values simply omit their element: a sidecar absent-field
|
||||
// means "no data", never "delete this from the file".
|
||||
func buildOPFMetadataInner(item *database.MediaItems) string {
|
||||
var b strings.Builder
|
||||
|
||||
title := strings.TrimSpace(item.Title)
|
||||
if title != "" {
|
||||
b.WriteString("\t<dc:title id=\"tmain\">" + escXML(title) + "</dc:title>\n")
|
||||
b.WriteString("\t<meta refines=\"#tmain\" property=\"title-type\">main</meta>\n")
|
||||
}
|
||||
if item.Subtitle.Valid && strings.TrimSpace(item.Subtitle.String) != "" {
|
||||
b.WriteString("\t<dc:title id=\"tsub\">" + escXML(strings.TrimSpace(item.Subtitle.String)) + "</dc:title>\n")
|
||||
b.WriteString("\t<meta refines=\"#tsub\" property=\"title-type\">subtitle</meta>\n")
|
||||
}
|
||||
for _, a := range item.Authors {
|
||||
a = strings.TrimSpace(a)
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("\t<dc:creator opf:role=\"aut\">" + escXML(a) + "</dc:creator>\n")
|
||||
}
|
||||
if len(item.Authors) == 0 && item.Author.Valid && strings.TrimSpace(item.Author.String) != "" {
|
||||
b.WriteString("\t<dc:creator opf:role=\"aut\">" + escXML(strings.TrimSpace(item.Author.String)) + "</dc:creator>\n")
|
||||
}
|
||||
if item.Description.Valid && strings.TrimSpace(item.Description.String) != "" {
|
||||
b.WriteString("\t<dc:description>" + escXML(item.Description.String) + "</dc:description>\n")
|
||||
}
|
||||
for _, p := range item.Publishers {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("\t<dc:publisher>" + escXML(p) + "</dc:publisher>\n")
|
||||
break // parser reads the first
|
||||
}
|
||||
if item.DatePublished.Valid {
|
||||
b.WriteString("\t<dc:date>" + item.DatePublished.Time.Format("2006-01-02") + "</dc:date>\n")
|
||||
}
|
||||
if item.Language.Valid && strings.TrimSpace(item.Language.String) != "" {
|
||||
b.WriteString("\t<dc:language>" + escXML(strings.TrimSpace(item.Language.String)) + "</dc:language>\n")
|
||||
}
|
||||
if item.Isbn.Valid && strings.TrimSpace(item.Isbn.String) != "" {
|
||||
b.WriteString("\t<dc:identifier opf:scheme=\"ISBN\">" + escXML(strings.TrimSpace(item.Isbn.String)) + "</dc:identifier>\n")
|
||||
}
|
||||
// The package's unique-identifier needs a matching element: the
|
||||
// scan's OPF uuid when known, else the item id (the parser files
|
||||
// urn:uuid under hash info, never metadata).
|
||||
uid := ""
|
||||
if item.OpfUuid.Valid {
|
||||
uid = strings.TrimSpace(item.OpfUuid.String)
|
||||
}
|
||||
if uid == "" && item.ID.Valid {
|
||||
uid = uuid.UUID(item.ID.Bytes).String()
|
||||
}
|
||||
if uid != "" {
|
||||
b.WriteString("\t<dc:identifier id=\"bookhoard-uuid\">urn:uuid:" + escXML(uid) + "</dc:identifier>\n")
|
||||
}
|
||||
for _, t := range item.Tags {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("\t<dc:subject>" + escXML(t) + "</dc:subject>\n")
|
||||
}
|
||||
if item.Series.Valid && strings.TrimSpace(item.Series.String) != "" {
|
||||
b.WriteString("\t<meta name=\"calibre:series\" content=\"" + escXML(item.Series.String) + "\"/>\n")
|
||||
if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 {
|
||||
b.WriteString("\t<meta name=\"calibre:series_index\" content=\"" + strconvFormatFloat(item.SeriesNumber.Float64) + "\"/>\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// metadataFieldNames lists the DB fields a write mirrors — response
|
||||
// contract for the UI ("what got written").
|
||||
func metadataFieldNames(item *database.MediaItems) []string {
|
||||
fields := []string{"title"}
|
||||
if item.Subtitle.Valid && item.Subtitle.String != "" {
|
||||
fields = append(fields, "subtitle")
|
||||
}
|
||||
if (len(item.Authors) > 0) || (item.Author.Valid && item.Author.String != "") {
|
||||
fields = append(fields, "authors")
|
||||
}
|
||||
if item.Description.Valid && item.Description.String != "" {
|
||||
fields = append(fields, "description")
|
||||
}
|
||||
if len(item.Publishers) > 0 || (item.Publisher.Valid && item.Publisher.String != "") {
|
||||
fields = append(fields, "publisher")
|
||||
}
|
||||
if item.DatePublished.Valid {
|
||||
fields = append(fields, "date_published")
|
||||
}
|
||||
if item.Language.Valid && item.Language.String != "" {
|
||||
fields = append(fields, "language")
|
||||
}
|
||||
if item.Isbn.Valid && item.Isbn.String != "" {
|
||||
fields = append(fields, "isbn")
|
||||
}
|
||||
if len(item.Tags) > 0 {
|
||||
fields = append(fields, "tags")
|
||||
}
|
||||
if item.Series.Valid && item.Series.String != "" {
|
||||
fields = append(fields, "series")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// strconvFormatFloat renders series indexes without trailing noise
|
||||
// (3 → "3", 3.5 → "3.5").
|
||||
func strconvFormatFloat(f float64) string {
|
||||
return fmt.Sprintf("%g", f)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Unit tests for the metadata writer's pure parts (2026-10-27): OPF
|
||||
// generation round-trips through the scanner's OWN parser
|
||||
// (parseOPFContent — write side and read side can never disagree), the
|
||||
// EPUB splice preserves manifest/spine, and the zip rewrite keeps
|
||||
// mimetype first + stored. The ffmpeg audio writer (5b) will extend
|
||||
// this file.
|
||||
|
||||
func fixtureItem() *database.MediaItems {
|
||||
pubDate, _ := time.Parse("2006-01-02", "1853-07-14")
|
||||
return &database.MediaItems{
|
||||
Title: `Progress & "Quotes" <etc>`,
|
||||
Subtitle: pgtype.Text{String: "A Subtitle", Valid: true},
|
||||
Authors: []string{"Ada Lovelace", "Grace Hopper"},
|
||||
Description: pgtype.Text{
|
||||
String: "A <b>description</b> with entities & such", Valid: true,
|
||||
},
|
||||
Publishers: []string{"Analytical Engine Press"},
|
||||
DatePublished: pgtype.Date{Time: pubDate, Valid: true},
|
||||
Language: pgtype.Text{String: "en", Valid: true},
|
||||
Isbn: pgtype.Text{String: "978-0-13-235088-4", Valid: true},
|
||||
Tags: []string{"Science", "Computing"},
|
||||
Series: pgtype.Text{String: "Scientific Memoirs", Valid: true},
|
||||
SeriesNumber: pgtype.Float8{Float64: 3, Valid: true},
|
||||
}
|
||||
}
|
||||
|
||||
// buildTestEPUB creates a minimal EPUB (mimetype + container + OPF with
|
||||
// manifest/spine) in memory.
|
||||
func buildTestEPUB(t *testing.T, opfContent string) []*zip.File {
|
||||
t.Helper()
|
||||
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(opfContent))
|
||||
|
||||
chapter, _ := w.Create("OEBPS/chapter1.xhtml")
|
||||
_, _ = chapter.Write([]byte("<html><body>Chapter</body></html>"))
|
||||
|
||||
require.NoError(t, w.Close())
|
||||
reader, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
require.NoError(t, err)
|
||||
return reader.File
|
||||
}
|
||||
|
||||
const spliceTestOPF = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<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">Old Title</dc:title>
|
||||
<dc:creator>Old Author</dc:creator>
|
||||
<dc:identifier id="pub-id">urn:uuid:1234</dc:identifier>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="ch1"/>
|
||||
</spine>
|
||||
</package>
|
||||
`
|
||||
|
||||
func TestMetadataWriteTargetFor(t *testing.T) {
|
||||
assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".epub"))
|
||||
assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".KEPUB"))
|
||||
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf"))
|
||||
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".m4b"))
|
||||
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cbz"))
|
||||
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(""))
|
||||
}
|
||||
|
||||
func TestBuildOPFMetadataInnerRoundTrips(t *testing.T) {
|
||||
item := fixtureItem()
|
||||
xmlInner := buildOPFMetadataInner(item)
|
||||
|
||||
// Wrap in a full package and parse with the SCANNER's parser: the
|
||||
// write must survive the read.
|
||||
doc := `<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">` +
|
||||
xmlInner + `</metadata></package>`
|
||||
md, err := parseOPFContent([]byte(doc))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Progress & \"Quotes\" <etc>: A Subtitle", md.Title, "main title + calibre-style subtitle join")
|
||||
assert.Equal(t, "Ada Lovelace", md.Author, "first creator")
|
||||
assert.Equal(t, "A <b>description</b> with entities & such", md.Description, "entities unescaped on read")
|
||||
assert.Equal(t, "Analytical Engine Press", md.Publisher)
|
||||
assert.Equal(t, "en", md.Language)
|
||||
assert.Equal(t, "9780132350884", md.ISBN, "normalized on read")
|
||||
assert.Equal(t, "Scientific Memoirs", md.Series)
|
||||
assert.Equal(t, float64(3), md.SeriesNumber)
|
||||
assert.ElementsMatch(t, []string{"Science", "Computing"}, md.Tags)
|
||||
require.NotNil(t, md.PublishDate)
|
||||
assert.Equal(t, "1853-07-14", md.PublishDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
func TestBuildOPFMetadataInnerOmitsEmptyFields(t *testing.T) {
|
||||
item := &database.MediaItems{Title: "Only A Title"}
|
||||
xmlInner := buildOPFMetadataInner(item)
|
||||
assert.NotContains(t, xmlInner, "dc:creator")
|
||||
assert.NotContains(t, xmlInner, "dc:description")
|
||||
assert.NotContains(t, xmlInner, "calibre:series")
|
||||
assert.NotContains(t, xmlInner, "dc:title id=\"tsub\"", "no subtitle element when unset")
|
||||
|
||||
// Singular-author fallback when the authors array is empty.
|
||||
item = &database.MediaItems{
|
||||
Title: "Fallback",
|
||||
Author: pgtype.Text{String: "Solo Author", Valid: true},
|
||||
}
|
||||
assert.Contains(t, buildOPFMetadataInner(item), "Solo Author")
|
||||
}
|
||||
|
||||
func TestSpliceOPFMetadataPreservesStructure(t *testing.T) {
|
||||
entries := buildTestEPUB(t, spliceTestOPF)
|
||||
item := fixtureItem()
|
||||
|
||||
newOPF, err := spliceOPFMetadata(entries, "OEBPS/content.opf", item)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The old metadata values are gone…
|
||||
out := string(newOPF)
|
||||
assert.NotContains(t, out, "Old Title")
|
||||
assert.NotContains(t, out, "Old Author")
|
||||
// …the new values are in…
|
||||
assert.Contains(t, out, `Progress & "Quotes" <etc>`)
|
||||
assert.Contains(t, out, "Ada Lovelace")
|
||||
// …and manifest/spine/identifier survive untouched.
|
||||
assert.Contains(t, out, `<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>`)
|
||||
assert.Contains(t, out, `<itemref idref="ch1"/>`)
|
||||
assert.Contains(t, out, `urn:uuid:1234`)
|
||||
assert.Contains(t, out, `unique-identifier="pub-id"`, "original opening tag preserved")
|
||||
|
||||
// The spliced OPF parses back with the scanner's parser.
|
||||
md, err := parseOPFContent(newOPF)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, item.Title+": A Subtitle", md.Title)
|
||||
}
|
||||
|
||||
func TestRewriteEPUBZipKeepsMimetypeFirstAndStored(t *testing.T) {
|
||||
entries := buildTestEPUB(t, spliceTestOPF)
|
||||
item := fixtureItem()
|
||||
newOPF, err := spliceOPFMetadata(entries, "OEBPS/content.opf", item)
|
||||
require.NoError(t, err)
|
||||
|
||||
tmp := filepath.Join(t.TempDir(), "book.epub")
|
||||
require.NoError(t, rewriteEPUBZip(entries, "OEBPS/content.opf", newOPF, tmp))
|
||||
|
||||
reader, err := zip.OpenReader(tmp)
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
|
||||
require.NotEmpty(t, reader.File)
|
||||
assert.Equal(t, "mimetype", reader.File[0].Name, "mimetype must be the first entry")
|
||||
assert.Equal(t, zip.Store, reader.File[0].FileHeader.Method, "mimetype must be uncompressed")
|
||||
|
||||
var opfEntry *zip.File
|
||||
names := []string{}
|
||||
for _, f := range reader.File {
|
||||
names = append(names, f.Name)
|
||||
if f.Name == "OEBPS/content.opf" {
|
||||
opfEntry = f
|
||||
}
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"mimetype", "META-INF/container.xml", "OEBPS/content.opf", "OEBPS/chapter1.xhtml"}, names)
|
||||
require.NotNil(t, opfEntry)
|
||||
rc, err := opfEntry.Open()
|
||||
require.NoError(t, err)
|
||||
content, err := io.ReadAll(rc)
|
||||
require.NoError(t, rc.Close())
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "Ada Lovelace")
|
||||
}
|
||||
|
||||
func TestAtomicWriteReplacesContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "metadata.opf")
|
||||
require.NoError(t, os.WriteFile(target, []byte("old"), 0o644))
|
||||
require.NoError(t, atomicWrite(target, []byte("new content")))
|
||||
got, err := os.ReadFile(target)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new content", string(got))
|
||||
// No temp litter left behind.
|
||||
entries, err := os.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 1)
|
||||
}
|
||||
@@ -251,6 +251,7 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
id="metadata-editor-modal"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
data-write-file-path={ book.FilePath }
|
||||
>
|
||||
<div
|
||||
class="card w-full max-w-5xl my-8 flex flex-col max-h-[90vh]"
|
||||
@@ -683,6 +684,18 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="writeMetadataToFile()"
|
||||
:disabled="writingFile"
|
||||
class="btn btn-secondary"
|
||||
:title="metadataWriteHint()"
|
||||
>
|
||||
<span x-show="!writingFile" class="inline-flex items-center gap-2">@Icon("save", "h-4 w-4")<span>Write to file</span></span>
|
||||
<svg x-show="writingFile" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -127,6 +127,8 @@ interface MetadataEditorState {
|
||||
saving: boolean;
|
||||
rescanning: boolean;
|
||||
resetting: boolean;
|
||||
writingFile: boolean;
|
||||
fileWritePath: string;
|
||||
userRating: number;
|
||||
ratingHover: number;
|
||||
ratingSaving: boolean;
|
||||
@@ -141,6 +143,9 @@ interface MetadataEditorState {
|
||||
saveMetadata(): Promise<void>;
|
||||
rescanBook(): Promise<void>;
|
||||
resetMetadata(): Promise<void>;
|
||||
writeMetadataToFile(): Promise<void>;
|
||||
metadataWriteTarget(): string;
|
||||
metadataWriteHint(): string;
|
||||
starFill(i: number): string;
|
||||
ratingText(): string;
|
||||
setRating(value: number): Promise<void>;
|
||||
@@ -181,6 +186,15 @@ Alpine.data("bookDetail", () => {
|
||||
saving: false,
|
||||
rescanning: false,
|
||||
resetting: false,
|
||||
// Write-to-file (2026-10-27): the modal root carries the item's
|
||||
// library-relative file path as a data attribute (same hydration
|
||||
// pattern as the editor tags above); the capability mirrors
|
||||
// services.MetadataWriteTargetFor.
|
||||
writingFile: false,
|
||||
fileWritePath: (() => {
|
||||
const modal = document.getElementById("metadata-editor-modal");
|
||||
return modal?.getAttribute("data-write-file-path") || "";
|
||||
})(),
|
||||
userRating: 0,
|
||||
ratingHover: 0,
|
||||
ratingSaving: false,
|
||||
@@ -873,6 +887,69 @@ Alpine.data("bookDetail", () => {
|
||||
}
|
||||
},
|
||||
|
||||
// metadataWriteTarget mirrors services.MetadataWriteTargetFor: EPUB
|
||||
// embeds its OPF, everything else takes a sidecar until the audio
|
||||
// (5b) and CBZ (5c) writers land.
|
||||
metadataWriteTarget(): string {
|
||||
const ext = (this.fileWritePath.split(".").pop() || "").toLowerCase();
|
||||
if (ext === "epub" || ext === "kepub") return "embedded-opf";
|
||||
return "sidecar-opf";
|
||||
},
|
||||
|
||||
metadataWriteHint(): string {
|
||||
if (this.metadataWriteTarget() === "embedded-opf") {
|
||||
return "Rewrite the metadata inside the EPUB, then rescan";
|
||||
}
|
||||
return "Write a Calibre-style metadata sidecar next to the file, then rescan";
|
||||
},
|
||||
|
||||
async writeMetadataToFile() {
|
||||
if (this.writingFile) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
this.metadataWriteTarget() === "embedded-opf"
|
||||
? "Rewrite the metadata inside this EPUB with the current saved values? The original file will be modified (the SHA-256 changes, so device syncs re-key this book)."
|
||||
: "Write a metadata sidecar next to this file with the current saved values? The media file itself is not modified.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.writingFile = true;
|
||||
const mediaId = getMediaId();
|
||||
try {
|
||||
const resp = await fetch(`/api/media-items/${mediaId}/write-metadata`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || "Failed to write metadata to file");
|
||||
}
|
||||
const payload = (await resp.json()) as {
|
||||
rescan?: string;
|
||||
metadata_target?: string;
|
||||
};
|
||||
if (payload.rescan && payload.rescan !== "ok") {
|
||||
showToast(
|
||||
`Written, but rescan failed: ${payload.rescan.replace(/^error: /, "")}`,
|
||||
"error",
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
payload.metadata_target === "embedded-opf"
|
||||
? "Metadata written into the EPUB and re-extracted"
|
||||
: "Metadata sidecar written and re-extracted",
|
||||
"success",
|
||||
);
|
||||
}
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "Failed to write metadata to file", "error");
|
||||
} finally {
|
||||
this.writingFile = false;
|
||||
}
|
||||
},
|
||||
|
||||
async resetMetadata() {
|
||||
if (this.resetting) return;
|
||||
if (
|
||||
|
||||
Reference in new issue
Block a user