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.
445 lines
18 KiB
Go
445 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"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",
|
|
"authors": []string{"Embedded Author"},
|
|
"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 — verified by
|
|
// PARSING the rewritten OPF (not string matching), with the
|
|
// ORIGINAL unique-identifier surviving the splice.
|
|
reader, err := zip.OpenReader(abs)
|
|
require.NoError(t, err)
|
|
var opfContent []byte
|
|
for _, f := range reader.File {
|
|
if f.Name == "OEBPS/content.opf" {
|
|
rc, err := f.Open()
|
|
require.NoError(t, err)
|
|
opfContent, err = io.ReadAll(rc)
|
|
require.NoError(t, rc.Close())
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
reader.Close()
|
|
|
|
var opf struct {
|
|
Metadata struct {
|
|
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
|
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
|
Metas []struct {
|
|
Name string `xml:"name,attr"`
|
|
Content string `xml:"content,attr"`
|
|
} `xml:"meta"`
|
|
} `xml:"metadata"`
|
|
}
|
|
require.NoError(t, xml.Unmarshal(opfContent, &opf))
|
|
require.NotEmpty(t, opf.Metadata.Titles, "dc:title present in written OPF")
|
|
assert.Equal(t, "Embedded New Title", opf.Metadata.Titles[0])
|
|
assert.NotContains(t, opf.Metadata.Titles, "Original EPUB Title", "old title replaced")
|
|
require.NotEmpty(t, opf.Metadata.Creators)
|
|
var seriesMeta string
|
|
for _, m := range opf.Metadata.Metas {
|
|
if m.Name == "calibre:series" {
|
|
seriesMeta = m.Content
|
|
}
|
|
}
|
|
assert.Equal(t, "Embedded Series", seriesMeta)
|
|
assert.Contains(t, string(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")
|
|
|
|
// Parse the sidecar like the scanner does: title must be present
|
|
// as a dc:title ELEMENT, not just a substring.
|
|
var opf struct {
|
|
Metadata struct {
|
|
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
|
} `xml:"metadata"`
|
|
}
|
|
require.NoError(t, xml.Unmarshal(data, &opf))
|
|
require.NotEmpty(t, opf.Metadata.Titles)
|
|
assert.Equal(t, "Sidecar New Title", opf.Metadata.Titles[0])
|
|
})
|
|
|
|
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.Run("audio tags rewrite round-trips for every container", func(t *testing.T) {
|
|
// Each single-file container gets its own generated fixture
|
|
// (ffmpeg at test runtime — no binary media in git), its own
|
|
// write, and an INDEPENDENT verification pass: ffprobe the
|
|
// rewritten file and assert every mapped key landed. This is the
|
|
// "check the file's metadata yourself" contract per format:
|
|
//
|
|
// album/artist/composer/grouping/date/genre → all four
|
|
// subtitle → mp3 + flac only
|
|
// (the mp4 muxer
|
|
// drops it)
|
|
cases := []struct {
|
|
ext string
|
|
audioCodec string
|
|
wantSubtitle bool
|
|
}{
|
|
{ext: ".mp3", audioCodec: "libmp3lame", wantSubtitle: true},
|
|
{ext: ".flac", audioCodec: "flac", wantSubtitle: true},
|
|
{ext: ".m4a", audioCodec: "aac", wantSubtitle: false},
|
|
{ext: ".m4b", audioCodec: "aac", wantSubtitle: false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.ext, func(t *testing.T) {
|
|
rel := fmt.Sprintf("test-metadata-write-%d/audio-%s/book%s", os.Getpid(), strings.TrimPrefix(tc.ext, "."), tc.ext)
|
|
abs := filepath.Join(uploadsDir, rel)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
|
gen := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
|
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
|
|
"-t", "2", "-c:a", tc.audioCodec,
|
|
"-metadata", "title=Generated Fixture",
|
|
"-metadata", "album=Generated Fixture",
|
|
abs)
|
|
if out, err := gen.CombinedOutput(); err != nil {
|
|
t.Skipf("fixture generation failed for %s (%v): %s", tc.ext, err, string(out))
|
|
}
|
|
|
|
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, 'Generated Fixture', $2, 'audiobook', $3, 100, NOW(), NOW(), NOW()) RETURNING id`,
|
|
libID, rel, "audio/"+strings.TrimPrefix(tc.ext, "."),
|
|
).Scan(&itemID)
|
|
require.NoError(t, err)
|
|
|
|
// Follow the real editor flow: PUT first (records
|
|
// metadata_overrides, so the inline rescan keeps the
|
|
// edited values instead of folder-structure derivations).
|
|
editItem(itemID, map[string]interface{}{
|
|
"title": "Audio New Title",
|
|
"subtitle": "Audio Sub",
|
|
"authors": []string{"Audio Author"},
|
|
"narrators": []string{"Narrator Person"},
|
|
"series": "Audio Series",
|
|
"series_number": 1.5,
|
|
"date_published": "1953-04-16",
|
|
"tags": []string{"Science"},
|
|
})
|
|
|
|
code, body := callWrite(setup.Token, itemID)
|
|
require.Equal(t, http.StatusOK, code, "%s write", tc.ext)
|
|
assert.Equal(t, "audio-tags", body["metadata_target"], tc.ext)
|
|
|
|
// ---- verify the FILE's metadata independently ----
|
|
probe := func() map[string]string {
|
|
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
|
|
"format_tags", "-of", "json", abs).Output()
|
|
require.NoError(t, err)
|
|
var parsed struct {
|
|
Format struct {
|
|
Tags map[string]string `json:"tags"`
|
|
} `json:"format"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(out, &parsed))
|
|
return parsed.Format.Tags
|
|
}
|
|
tags := probe()
|
|
assert.Equal(t, "Audio New Title", tags["album"], "%s: the scanner's album rung", tc.ext)
|
|
assert.Equal(t, "Audio New Title", tags["title"], tc.ext)
|
|
assert.Equal(t, "Audio Author", tags["artist"], tc.ext)
|
|
assert.Equal(t, "Narrator Person", tags["composer"], "%s: narrators ride composer", tc.ext)
|
|
assert.Equal(t, "Audio Series", tags["grouping"], tc.ext)
|
|
assert.Equal(t, "Science", tags["genre"], tc.ext)
|
|
assert.Equal(t, "1953-04-16", tags["date"], tc.ext)
|
|
assert.NotContains(t, tags["album"], "Generated Fixture", "%s: old tag replaced", tc.ext)
|
|
if tc.wantSubtitle {
|
|
assert.Equal(t, "Audio Sub", tags["subtitle"], "%s: subtitle round-trips", tc.ext)
|
|
} else {
|
|
assert.Empty(t, tags["subtitle"], "%s: subtitle must not be written where it can't round-trip", tc.ext)
|
|
writeMap := body["write"].(map[string]interface{})
|
|
assert.NotContains(t, writeMap["fields_written"], "subtitle", "%s: honest fields_written", tc.ext)
|
|
}
|
|
|
|
// Still valid, same-length audio after the rewrite.
|
|
durOut, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
|
|
"format=duration", "-of", "default=noprint_wrappers=1:nokey=1", abs).Output()
|
|
require.NoError(t, err)
|
|
dur, err := strconv.ParseFloat(strings.TrimSpace(string(durOut)), 64)
|
|
require.NoError(t, err)
|
|
assert.Greater(t, dur, 1.5, "%s: audio survived the rewrite", tc.ext)
|
|
|
|
// The inline rescan re-read the tags: the DB title stays
|
|
// the written one.
|
|
var title string
|
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
|
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
|
|
assert.Equal(t, "Audio New Title", title, tc.ext)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("CBZ gets ComicInfo.xml injected and rescan keeps it", func(t *testing.T) {
|
|
rel := fmt.Sprintf("test-metadata-write-%d/comic/book.cbz", os.Getpid())
|
|
abs := filepath.Join(uploadsDir, rel)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
|
// Minimal archive: one "page" file (a text stub — cover
|
|
// extraction failing is tolerated by the scanner) and no
|
|
// ComicInfo.xml yet.
|
|
var buf bytes.Buffer
|
|
w := zip.NewWriter(&buf)
|
|
page, _ := w.Create("page_0001.jpg")
|
|
_, _ = page.Write([]byte("not-a-real-jpeg"))
|
|
require.NoError(t, w.Close())
|
|
require.NoError(t, os.WriteFile(abs, buf.Bytes(), 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, 'Generated Comic', $2, 'comic_archive', 'application/zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
|
|
libID, rel,
|
|
).Scan(&itemID)
|
|
require.NoError(t, err)
|
|
|
|
editItem(itemID, map[string]interface{}{
|
|
"title": "Comic New Title",
|
|
"series": "Comic Series",
|
|
"series_number": 4,
|
|
"authors": []string{"Comic Writer"},
|
|
"tags": []string{"Superhero"},
|
|
})
|
|
|
|
code, body := callWrite(setup.Token, itemID)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, "comicinfo", body["metadata_target"])
|
|
|
|
// ---- verify the FILE's ComicInfo.xml by parsing it ----
|
|
reader, err := zip.OpenReader(abs)
|
|
require.NoError(t, err)
|
|
var infoData []byte
|
|
for _, f := range reader.File {
|
|
if f.Name == "ComicInfo.xml" {
|
|
rc, err := f.Open()
|
|
require.NoError(t, err)
|
|
infoData, err = io.ReadAll(rc)
|
|
require.NoError(t, rc.Close())
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
reader.Close()
|
|
require.NotEmpty(t, infoData, "ComicInfo.xml must exist inside the CBZ")
|
|
|
|
var info struct {
|
|
Title string `xml:"Title"`
|
|
Series string `xml:"Series"`
|
|
Number int `xml:"Number"`
|
|
Writer string `xml:"Writer"`
|
|
}
|
|
require.NoError(t, xml.Unmarshal(infoData, &info))
|
|
assert.Equal(t, "Comic New Title", info.Title)
|
|
assert.Equal(t, "Comic Series", info.Series)
|
|
assert.Equal(t, 4, info.Number)
|
|
assert.Equal(t, "Comic Writer", info.Writer)
|
|
|
|
// The inline rescan reads the ComicInfo back: the DB title and
|
|
// series stay the written values.
|
|
var title, series string
|
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
|
`SELECT title, series FROM media_items WHERE id = $1`, itemID).Scan(&title, &series))
|
|
assert.Equal(t, "Comic New Title", title)
|
|
assert.Equal(t, "Comic Series", series)
|
|
})
|
|
|
|
t.Cleanup(func() {
|
|
_ = os.RemoveAll(filepath.Join(uploadsDir, fmt.Sprintf("test-metadata-write-%d", os.Getpid())))
|
|
})
|
|
}
|