diff --git a/cmd/server/tests/metadata_write_test.go b/cmd/server/tests/metadata_write_test.go new file mode 100644 index 00000000..6dca25fb --- /dev/null +++ b/cmd/server/tests/metadata_write_test.go @@ -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, .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(``)) + opf, _ := w.Create("OEBPS/content.opf") + _, _ = opf.Write([]byte(fmt.Sprintf(`%sOriginal Authorurn:uuid:abcd`, title))) + ch, _ := w.Create("OEBPS/c1.xhtml") + _, _ = ch.Write([]byte("x")) + 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 .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 .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()))) + }) +} diff --git a/docs/user/calibre-integration.md b/docs/user/calibre-integration.md index 4cd81ffd..4f93752e 100644 --- a/docs/user/calibre-integration.md +++ b/docs/user/calibre-integration.md @@ -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 `.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/) diff --git a/internal/database/querier.go b/internal/database/querier.go index fa000a96..f6d174d4 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -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 + // .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 diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 7083c8d8..652cc2db 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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 +// .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 diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index f6fce6e3..b7b836b3 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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 +-- .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 diff --git a/internal/handlers/media.go b/internal/handlers/media.go index fe644532..f61b85ed 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -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) diff --git a/internal/router/media.go b/internal/router/media.go index 2ccec3e6..d1fbd588 100644 --- a/internal/router/media.go +++ b/internal/router/media.go @@ -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) diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 34d50efb..348fc9d6 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -1773,23 +1773,24 @@ 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 (.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) + continue // fall back to the next candidate / embedded + } + return metadata } - - // 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 - } - - return metadata + return nil } // applyEmbeddedCoverFallback extracts a cover from the media file itself when diff --git a/internal/services/metadata_writer.go b/internal/services/metadata_writer.go new file mode 100644 index 00000000..7abfe71f --- /dev/null +++ b/internal/services/metadata_writer.go @@ -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 `.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 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 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(" element") + } + openEnd := bytes.Index(original[metaStart:], []byte(">")) + if openEnd == -1 { + return nil, fmt.Errorf("OPF tag malformed") + } + metaClose := bytes.Index(original[metaStart:], []byte("")) + if metaClose == -1 { + return nil, fmt.Errorf("OPF has no 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("")) + if end == -1 { + break + } + end += start + len("") + 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(`` + "\n") + b.WriteString(`` + "\n") + b.WriteString(buildOPFMetadataInner(item)) + b.WriteString("\n\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" + escXML(title) + "\n") + b.WriteString("\tmain\n") + } + if item.Subtitle.Valid && strings.TrimSpace(item.Subtitle.String) != "" { + b.WriteString("\t" + escXML(strings.TrimSpace(item.Subtitle.String)) + "\n") + b.WriteString("\tsubtitle\n") + } + for _, a := range item.Authors { + a = strings.TrimSpace(a) + if a == "" { + continue + } + b.WriteString("\t" + escXML(a) + "\n") + } + if len(item.Authors) == 0 && item.Author.Valid && strings.TrimSpace(item.Author.String) != "" { + b.WriteString("\t" + escXML(strings.TrimSpace(item.Author.String)) + "\n") + } + if item.Description.Valid && strings.TrimSpace(item.Description.String) != "" { + b.WriteString("\t" + escXML(item.Description.String) + "\n") + } + for _, p := range item.Publishers { + p = strings.TrimSpace(p) + if p == "" { + continue + } + b.WriteString("\t" + escXML(p) + "\n") + break // parser reads the first + } + if item.DatePublished.Valid { + b.WriteString("\t" + item.DatePublished.Time.Format("2006-01-02") + "\n") + } + if item.Language.Valid && strings.TrimSpace(item.Language.String) != "" { + b.WriteString("\t" + escXML(strings.TrimSpace(item.Language.String)) + "\n") + } + if item.Isbn.Valid && strings.TrimSpace(item.Isbn.String) != "" { + b.WriteString("\t" + escXML(strings.TrimSpace(item.Isbn.String)) + "\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("\turn:uuid:" + escXML(uid) + "\n") + } + for _, t := range item.Tags { + t = strings.TrimSpace(t) + if t == "" { + continue + } + b.WriteString("\t" + escXML(t) + "\n") + } + if item.Series.Valid && strings.TrimSpace(item.Series.String) != "" { + b.WriteString("\t\n") + if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 { + b.WriteString("\t\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) +} diff --git a/internal/services/metadata_writer_test.go b/internal/services/metadata_writer_test.go new file mode 100644 index 00000000..8852c8b8 --- /dev/null +++ b/internal/services/metadata_writer_test.go @@ -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" `, + Subtitle: pgtype.Text{String: "A Subtitle", Valid: true}, + Authors: []string{"Ada Lovelace", "Grace Hopper"}, + Description: pgtype.Text{ + String: "A description 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(``)) + + opf, _ := w.Create("OEBPS/content.opf") + _, _ = opf.Write([]byte(opfContent)) + + chapter, _ := w.Create("OEBPS/chapter1.xhtml") + _, _ = chapter.Write([]byte("Chapter")) + + 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 = ` + + + Old Title + Old Author + urn:uuid:1234 + + + + + + + + +` + +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 := `` + + xmlInner + `` + md, err := parseOPFContent([]byte(doc)) + require.NoError(t, err) + + assert.Equal(t, "Progress & \"Quotes\" : A Subtitle", md.Title, "main title + calibre-style subtitle join") + assert.Equal(t, "Ada Lovelace", md.Author, "first creator") + assert.Equal(t, "A description 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, ``) + assert.Contains(t, out, ``) + 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) +} diff --git a/templates/book_detail_modals.templ b/templates/book_detail_modals.templ index 73594a5a..26204ef4 100644 --- a/templates/book_detail_modals.templ +++ b/templates/book_detail_modals.templ @@ -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 } >
+
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.CoverImagePath.Valid && book.CoverImagePath.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\"")") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\"")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" class=\"w-full h-full object-cover\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\"")") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
Click to upload
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, tag := range book.Tags { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" class=\"hidden\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.IsAudio() { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.AudioDuration() > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var61 string - templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel()) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 540, Col: 129} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\" class=\"input\">
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.IsAudio() { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
Unknown
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.AudioDuration() > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var62 string + templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(book.AudioDurationLabel()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 541, Col: 129} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.ShowComicMetadata() { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "
Right to Left
Vertical
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, " class=\"rounded\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "Write to file
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts index 0a1cf8bc..f94371ce 100644 --- a/web/src/book-detail.ts +++ b/web/src/book-detail.ts @@ -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; rescanBook(): Promise; resetMetadata(): Promise; + writeMetadataToFile(): Promise; + metadataWriteTarget(): string; + metadataWriteHint(): string; starFill(i: number): string; ratingText(): string; setRating(value: number): Promise; @@ -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 (