Files
bookhoard/internal/services/metadata_writer_test.go
T
John O'Keefe fb1645c90c fix(metadata): m4b text-track fallback; in-app confirm; save-first write
Three defects from real-world use:

- The m4b rewrite assumed every stream survives -map 0 -c copy; files
  carrying text-ish tracks (the movenc 'text' fourcc) fail the ipod
  muxer with "Tag text incompatible with output codec id" — exactly the
  error a write attempt hit in the field. The rewrite now attempts full
  fidelity first, then retries mapping audio + attached pictures only
  (-dn -sn): chapters live in the container header (-map_chapters 0)
  so they survive; every attempt still ffprobe-validates before the
  atomic rename and the original is never touched on total failure.

- The browser confirm() is replaced by an in-app dialog
  (WriteToFileConfirmDialog, Alpine + templ — no native dialogs).

- "Write to file" now SAVES the pending editor edits first (persisted
  through the same PUT the Save button uses), then writes the file
  from those DB values, and closes the editor + refreshes the page
  exactly like Save — previously the endpoint wrote the LAST-SAVED DB
  values, so edits sitting in the modal never reached the file, and
  the page didn't visibly update.

Test coverage per the owner's bar: EVERY single-file container (MP3,
FLAC, M4A, M4B) gets its own runtime-generated fixture, gets written,
and the FILE's metadata is then verified independently — ffprobe reads
the rewritten container and every mapped key is asserted per format,
including the subtitle round-trip split (MP3/FLAC yes, M4A/B no) — and
the EPUB/CBZ/sidecar assertions now PARSE the written XML (dc titles/
creators, ComicInfo fields, calibre meta) instead of substring
matching. A smart-genre unit test also pins the mixed-blob contract: a
recognized shelf wins the genre column even when demoted format words
ride along in the same blob.
2026-10-27 19:05:23 -04:00

353 lines
14 KiB
Go

package services
import (
"archive/zip"
"bytes"
"encoding/xml"
"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(""))
}
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 &amp; &quot;Quotes&quot; &lt;etc&gt;`)
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)
}
func TestAudioTagArgsMapsRoundTripKeys(t *testing.T) {
// Every key here must have a reader in applyAudioTagFallbacks —
// that's the round-trip contract with the scanner.
item := &database.MediaItems{
Title: "The Book",
Subtitle: pgtype.Text{String: "The Sub", Valid: true},
Authors: []string{"Ada Lovelace", "Grace Hopper"},
Narrators: []string{"Simon Vance"},
Series: pgtype.Text{String: "Memoirs", Valid: true},
SeriesNumber: pgtype.Float8{Float64: 3.5, Valid: true},
DatePublished: pgtype.Date{
Time: dateOf("1953-04-16"),
Valid: true,
},
Tags: []string{"Science"},
}
got := map[string]string{}
for _, kv := range audioTagArgs(item, ".mp3") {
got[kv[0]] = kv[1]
}
assert.Equal(t, "The Book", got["title"])
assert.Equal(t, "The Book", got["album"], "the scanner's album rung names the book")
assert.Equal(t, "The Sub", got["subtitle"])
assert.Equal(t, "Ada Lovelace", got["artist"], "first author")
assert.Equal(t, "Ada Lovelace & Grace Hopper", got["album_artist"])
assert.Equal(t, "Simon Vance", got["composer"], "narrators ride composer (Audible convention)")
assert.Equal(t, "Memoirs", got["grouping"])
assert.Equal(t, "3.5", got["----:com.apple.itunes:series-part"])
assert.Equal(t, "1953-04-16", got["date"])
assert.Equal(t, "Science", got["genre"])
// Empty fields emit nothing (a tag write must not blank a book).
empty := audioTagArgs(&database.MediaItems{Title: "T"}, ".mp3")
keys := map[string]bool{}
for _, kv := range empty {
keys[kv[0]] = true
}
assert.True(t, keys["title"])
assert.False(t, keys["subtitle"])
assert.False(t, keys["artist"])
assert.False(t, keys["composer"])
assert.False(t, keys["grouping"])
assert.False(t, keys["genre"])
// M4B drops subtitle keys in this ffmpeg build — never emitted.
m4b := audioTagArgs(item, ".m4b")
for _, kv := range m4b {
assert.NotEqual(t, "subtitle", kv[0], "mp4 muxer drops subtitle; don't write it")
}
}
// Single-file audio dispatch (5b): the four containers the ffmpeg tag
// writer claims; folder/multi-file audio stays on the sidecar path.
func TestAudioTagExts(t *testing.T) {
for _, ext := range []string{".mp3", ".flac", ".m4a", ".m4b"} {
assert.Equal(t, MetadataTargetAudioTags, MetadataWriteTargetFor(ext), ext)
}
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".opus"))
}
// CBZ dispatch (5c): ComicInfo injection claims CBZ; CBR/CB7 can't be
// rewritten in place and stay on the sidecar path.
func TestComicInfoTargetFor(t *testing.T) {
assert.Equal(t, MetadataTargetComicInfo, MetadataWriteTargetFor(".cbz"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cbr"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cb7"))
}
// buildComicInfoXML must round-trip through the scanner's OWN ComicInfo
// struct (xml.Unmarshal — same parser extractMetadataFromArchive uses).
func TestBuildComicInfoXMLRoundTrips(t *testing.T) {
pubDate, _ := time.Parse("2006-01-02", "1987-03-10")
item := &database.MediaItems{
Title: "The Sad Comic <#1>",
Series: pgtype.Text{String: "Watchmen", Valid: true},
SeriesNumber: pgtype.Float8{Float64: 3, Valid: true},
SeriesCount: pgtype.Int4{Int32: 12, Valid: true},
Authors: []string{"Alan Moore", "Dave Gibbons"},
Description: pgtype.Text{String: "Description & <entities>", Valid: true},
Publishers: []string{"DC Comics"},
DatePublished: pgtype.Date{
Time: pubDate,
Valid: true,
},
Language: pgtype.Text{String: "en", Valid: true},
WebUrl: pgtype.Text{String: "https://example.com/watchmen", Valid: true},
Tags: []string{"Superhero", "Drama"},
}
parsed := ComicInfo{}
require.NoError(t, xml.Unmarshal([]byte(buildComicInfoXML(item)), &parsed))
assert.Equal(t, "The Sad Comic <#1>", parsed.Title)
assert.Equal(t, "Watchmen", parsed.Series)
assert.Equal(t, 3, parsed.Number)
assert.Equal(t, 12, parsed.Count)
assert.Equal(t, "Alan Moore, Dave Gibbons", parsed.Writer)
assert.Equal(t, "Description & <entities>", parsed.Summary)
assert.Equal(t, "DC Comics", parsed.Publisher)
assert.Equal(t, 1987, parsed.Year)
assert.Equal(t, 3, parsed.Month)
assert.Equal(t, 10, parsed.Day)
assert.Equal(t, "en", parsed.LanguageISO)
assert.Equal(t, "https://example.com/watchmen", parsed.Web)
assert.Equal(t, "Superhero", parsed.Genre, "first tag doubles as genre")
assert.Equal(t, "Superhero, Drama", parsed.Tags)
}
func TestBuildComicInfoXMLSkipsFractionalNumber(t *testing.T) {
// Number is an integer field; a fractional series number must not
// round-trip truncated (a lie).
item := &database.MediaItems{
Title: "X",
Series: pgtype.Text{String: "S", Valid: true},
SeriesNumber: pgtype.Float8{Float64: 3.5, Valid: true},
}
parsed := ComicInfo{}
require.NoError(t, xml.Unmarshal([]byte(buildComicInfoXML(item)), &parsed))
assert.Equal(t, 0, parsed.Number, "fractional series number omitted, not truncated")
assert.NotContains(t, buildComicInfoXML(item), "<Number>")
}
// The demoted-genre contract in full (885d6140): a format word only
// sinks to tags-only when it's ALONE. A blob where ANY candidate maps
// to a shelf still wins the genre column; the demoted word never does.
func TestPickSmartGenreMixedBlob(t *testing.T) {
genre, all := pickSmartGenre([]string{"Audiobook, Science Fiction, Unabridged"})
assert.Equal(t, "science fiction", genre, "a recognized shelf wins the column")
assert.Contains(t, all, "Audiobook", "the demoted word is still a tag candidate")
assert.Contains(t, all, "Unabridged")
// Format words alone → column empty, everything tags-only.
genre, all = pickSmartGenre([]string{"Audiobook, Unabridged"})
assert.Empty(t, genre, "demoted words alone never fill the genre column")
assert.Contains(t, all, "Audiobook")
assert.Contains(t, all, "Unabridged")
}
func dateOf(s string) time.Time {
tm, _ := time.Parse("2006-01-02", s)
return tm
}