Phases 5b and 5c of the metadata writer. 5b — single-file audio (MP3/FLAC/M4A/M4B): the container's tags are rewritten with ffmpeg -c copy (no re-encode; -map 0 keeps cover art, -map_chapters 0 keeps chapters). The key set is exactly what the scanner's applyAudioTagFallbacks reads back — title+album, subtitle, artist/album_artist, composer (narrators, Audible's convention), grouping + iTunes series-part, date, genre — so a write + rescan round-trips. Output is ffprobe-VALIDATED (duration within 1s/0.5% of the source) before the atomic rename; a lossy container rewrite never replaces a good book. Fields the format can't carry per our own read chain are deliberately not written, and fields_written reports only what really round-trips (subtitle rides MP3/FLAC — this image's mp4 muxer drops subtitle keys, discovered empirically and pinned by test). 5c — CBZ: ComicInfo.xml injected (or replaced) with the same atomic zip rewrite as the EPUB path, extended with an append-if-missing mode since a comic may not carry one yet. Field map mirrors the scanner's comic block: Title/Series/Number/Count/Volume, Writer (authors), Summary, Publisher, Year/Month/Day, LanguageISO, Web, Genre+Tags; fractional series numbers are omitted rather than truncated to a lie. CBR/CB7 stay on the sidecar path (RAR/7z can't be rewritten in place). Integration tests generate media at test runtime with ffmpeg (m4b) and in-memory zips (cbz) — no binary fixtures — pinning the tag round-trip through ffprobe, post-write rescan titles, the ComicInfo injection and the validation-gate behavior.
337 lines
13 KiB
Go
337 lines
13 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 & "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)
|
|
}
|
|
|
|
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>")
|
|
}
|
|
|
|
func dateOf(s string) time.Time {
|
|
tm, _ := time.Parse("2006-01-02", s)
|
|
return tm
|
|
}
|