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.
379 lines
16 KiB
Go
379 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"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", "series": "Embedded Series", "series_number": 2})
|
|
|
|
code, body := callWrite(setup.Token, itemID)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, "embedded-opf", body["metadata_target"])
|
|
|
|
// The zip on disk now carries the new values, and the ORIGINAL
|
|
// unique-identifier survives the splice.
|
|
reader, err := zip.OpenReader(abs)
|
|
require.NoError(t, err)
|
|
defer reader.Close()
|
|
var opfContent string
|
|
for _, f := range reader.File {
|
|
if f.Name == "OEBPS/content.opf" {
|
|
rc, err := f.Open()
|
|
require.NoError(t, err)
|
|
buf := new(strings.Builder)
|
|
_, err = io.Copy(buf, rc)
|
|
require.NoError(t, rc.Close())
|
|
require.NoError(t, err)
|
|
opfContent = buf.String()
|
|
}
|
|
}
|
|
assert.Contains(t, opfContent, "Embedded New Title")
|
|
assert.Contains(t, opfContent, "Embedded Series")
|
|
assert.NotContains(t, opfContent, "Original EPUB Title")
|
|
assert.Contains(t, opfContent, "urn:uuid:abcd", "original identifier preserved for unique-identifier")
|
|
|
|
// The inline rescan re-extracted from the file: the DB title
|
|
// still reads the written value.
|
|
var title string
|
|
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
|
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
|
|
assert.Equal(t, "Embedded New Title", title)
|
|
})
|
|
|
|
t.Run("single-book dir gets a Calibre-style metadata.opf sidecar", func(t *testing.T) {
|
|
rel := fmt.Sprintf("test-metadata-write-%d/alone/book.pdf", os.Getpid())
|
|
abs := filepath.Join(uploadsDir, rel)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
|
require.NoError(t, os.WriteFile(abs, []byte("%PDF-1.4 fake"), 0o644))
|
|
|
|
var itemID string
|
|
err := setup.DBPool.QueryRow(ctx,
|
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
|
VALUES ($1, 'Sidecar Book', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
|
|
libID, rel,
|
|
).Scan(&itemID)
|
|
require.NoError(t, err)
|
|
editItem(itemID, map[string]interface{}{"title": "Sidecar New Title"})
|
|
|
|
code, body := callWrite(setup.Token, itemID)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, "sidecar-opf", body["metadata_target"])
|
|
|
|
sidecar := filepath.Join(filepath.Dir(abs), "metadata.opf")
|
|
data, err := os.ReadFile(sidecar)
|
|
require.NoError(t, err, "metadata.opf must exist next to a lone book")
|
|
assert.Contains(t, string(data), "Sidecar New Title")
|
|
})
|
|
|
|
t.Run("shared dir falls back to <basename>.opf", func(t *testing.T) {
|
|
// PDFs can't carry metadata, so they take the sidecar path —
|
|
// and with two books in one directory, the sidecar must be
|
|
// per-file or A's write would poison B's next scan.
|
|
dir := fmt.Sprintf("test-metadata-write-%d/shared", os.Getpid())
|
|
relA, relB := dir+"/a.pdf", dir+"/b.pdf"
|
|
absA, absB := filepath.Join(uploadsDir, relA), filepath.Join(uploadsDir, relB)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(absA), 0o755))
|
|
require.NoError(t, os.WriteFile(absA, []byte("%PDF-1.4 fake a"), 0o644))
|
|
require.NoError(t, os.WriteFile(absB, []byte("%PDF-1.4 fake b"), 0o644))
|
|
|
|
var itemA string
|
|
err := setup.DBPool.QueryRow(ctx,
|
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
|
VALUES ($1, 'Book A', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
|
|
libID, relA,
|
|
).Scan(&itemA)
|
|
require.NoError(t, err)
|
|
var itemB string
|
|
err = setup.DBPool.QueryRow(ctx,
|
|
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
|
|
VALUES ($1, 'Book B', $2, 'ebook', 'application/pdf', 10, NOW(), NOW(), NOW()) RETURNING id`,
|
|
libID, relB,
|
|
).Scan(&itemB)
|
|
require.NoError(t, err)
|
|
|
|
editItem(itemA, map[string]interface{}{"title": "Book A Rewritten"})
|
|
|
|
code, body := callWrite(setup.Token, itemA)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Equal(t, "sidecar-opf", body["metadata_target"])
|
|
|
|
// a.opf carries A's metadata; NO dir-scoped metadata.opf
|
|
// may appear (it would poison B's next scan), and B gets no
|
|
// sidecar from A's write. The name strips the extension,
|
|
// matching the scanner's {basename}.jpg cover convention.
|
|
data, err := os.ReadFile(filepath.Join(uploadsDir, dir, "a.opf"))
|
|
require.NoError(t, err, "shared dir must use <basename>.opf")
|
|
assert.Contains(t, string(data), "Book A Rewritten")
|
|
_, err = os.Stat(filepath.Join(uploadsDir, dir, "metadata.opf"))
|
|
assert.True(t, os.IsNotExist(err), "no dir-scoped sidecar over a multi-book folder")
|
|
_, err = os.Stat(filepath.Join(uploadsDir, dir, "b.opf"))
|
|
assert.True(t, os.IsNotExist(err), "B gets no sidecar from A's write")
|
|
_ = itemB
|
|
})
|
|
|
|
t.Run("m4b tags are rewritten via ffmpeg and survive rescan", func(t *testing.T) {
|
|
rel := fmt.Sprintf("test-metadata-write-%d/audio/book.m4b", os.Getpid())
|
|
abs := filepath.Join(uploadsDir, rel)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
|
|
// Generate the fixture at test runtime (AGENTS: no binary media
|
|
// in git): 2s of silence through the native AAC encoder, with
|
|
// an original title tag to prove the rewrite replaces it.
|
|
gen := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
|
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
|
|
"-t", "2", "-c:a", "aac",
|
|
"-metadata", "title=Generated Fixture",
|
|
"-metadata", "album=Generated Fixture",
|
|
abs)
|
|
out, err := gen.CombinedOutput()
|
|
require.NoError(t, err, "ffmpeg fixture generation failed: %s", 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', 'audio/mp4', 100, NOW(), NOW(), NOW()) RETURNING id`,
|
|
libID, rel,
|
|
).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)
|
|
assert.Equal(t, "audio-tags", body["metadata_target"])
|
|
|
|
// The container now carries the round-trip keys (probe with the
|
|
// same tool the scanner uses).
|
|
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"], "the scanner's album rung")
|
|
assert.Equal(t, "Audio New Title", tags["title"])
|
|
// This ffmpeg's mp4 muxer drops subtitle keys — nothing that
|
|
// can't be read back may be written (fields_written omits it).
|
|
assert.Empty(t, tags["subtitle"], "subtitle must not be written where it can't round-trip")
|
|
assert.Equal(t, "Audio Author", tags["artist"])
|
|
assert.Equal(t, "Narrator Person", tags["composer"])
|
|
assert.Equal(t, "Audio Series", tags["grouping"])
|
|
assert.Equal(t, "Science", tags["genre"])
|
|
assert.NotContains(t, tags["album"], "Generated Fixture", "old tag replaced")
|
|
|
|
writeMap := body["write"].(map[string]interface{})
|
|
assert.NotContains(t, writeMap["fields_written"], "subtitle", "honest fields_written")
|
|
|
|
// Still a valid, same-length audio file after the rewrite.
|
|
dur := func() float64 {
|
|
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
|
|
"format=duration", "-of", "default=noprint_wrappers=1:nokey=1", abs).Output()
|
|
require.NoError(t, err)
|
|
v, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
|
|
require.NoError(t, err)
|
|
return v
|
|
}
|
|
assert.Greater(t, dur(), 1.5, "audio survived the rewrite")
|
|
|
|
// The inline rescan re-read the tags: 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)
|
|
})
|
|
|
|
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"])
|
|
|
|
reader, err := zip.OpenReader(abs)
|
|
require.NoError(t, err)
|
|
var infoXML string
|
|
for _, f := range reader.File {
|
|
if f.Name == "ComicInfo.xml" {
|
|
rc, err := f.Open()
|
|
require.NoError(t, err)
|
|
data, err := io.ReadAll(rc)
|
|
require.NoError(t, rc.Close())
|
|
require.NoError(t, err)
|
|
infoXML = string(data)
|
|
}
|
|
}
|
|
reader.Close()
|
|
assert.Contains(t, infoXML, "<Title>Comic New Title</Title>")
|
|
assert.Contains(t, infoXML, "<Series>Comic Series</Series>")
|
|
assert.Contains(t, infoXML, "<Number>4</Number>")
|
|
assert.Contains(t, infoXML, "<Writer>Comic Writer</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())))
|
|
})
|
|
}
|