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, .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", "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 .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.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()))) }) }