diff --git a/cmd/server/tests/metadata_write_test.go b/cmd/server/tests/metadata_write_test.go index e4c91de3..7e0641e6 100644 --- a/cmd/server/tests/metadata_write_test.go +++ b/cmd/server/tests/metadata_write_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "encoding/xml" "fmt" "io" "net/http" @@ -114,33 +115,57 @@ func TestMetadataWriteToFile(t *testing.T) { 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}) + 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, and the ORIGINAL - // unique-identifier survives the splice. + // 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) - defer reader.Close() - var opfContent string + var opfContent []byte 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) + opfContent, err = io.ReadAll(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") + 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. @@ -172,7 +197,17 @@ func TestMetadataWriteToFile(t *testing.T) { 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") + + // 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) { @@ -221,93 +256,115 @@ func TestMetadataWriteToFile(t *testing.T) { _ = 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 + 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}, } - 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") + 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)) + } - // 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 + 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) + }) } - 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) { @@ -344,24 +401,33 @@ func TestMetadataWriteToFile(t *testing.T) { 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 infoXML string + var infoData []byte for _, f := range reader.File { if f.Name == "ComicInfo.xml" { rc, err := f.Open() require.NoError(t, err) - data, err := io.ReadAll(rc) + infoData, err = io.ReadAll(rc) require.NoError(t, rc.Close()) require.NoError(t, err) - infoXML = string(data) } } reader.Close() - assert.Contains(t, infoXML, "Comic New Title") - assert.Contains(t, infoXML, "Comic Series") - assert.Contains(t, infoXML, "4") - assert.Contains(t, infoXML, "Comic Writer") + 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. diff --git a/internal/services/metadata_writer.go b/internal/services/metadata_writer.go index 3823627f..c9d2c0d2 100644 --- a/internal/services/metadata_writer.go +++ b/internal/services/metadata_writer.go @@ -559,11 +559,14 @@ func probeDurationSeconds(path string) float64 { } // writeAudioTags rewrites the container's tags via ffmpeg stream copy: -// `-c copy` never re-encodes, `-map 0` keeps every stream (cover art -// included), `-map_metadata 0` preserves existing global tags, and -// `-map_chapters 0` keeps the m4b chapter list. Output is validated -// (duration within tolerance of the source) BEFORE the atomic rename — -// a lossy container rewrite must never replace a good book. +// `-c copy` never re-encodes, `-map_metadata 0` preserves existing +// global tags, and `-map_chapters 0` keeps the m4b chapter list +// (chapters live in the header, not a stream). Attempt 1 copies every +// stream; attempt 2 — for real-world files carrying text/subtitle/data +// tracks the ipod muxer rejects ("Tag text incompatible with output +// codec id ...") — maps audio + attached pictures only. Every attempt +// is ffprobe-VALIDATED (duration within tolerance of the source) +// BEFORE the atomic rename; a failure never touches the original. func (w *MetadataWriter) writeAudioTags(absPath string, item *database.MediaItems) (string, error) { info, err := os.Stat(absPath) if err != nil { @@ -577,41 +580,58 @@ func (w *MetadataWriter) writeAudioTags(absPath string, item *database.MediaItem ext := strings.ToLower(filepath.Ext(absPath)) tmpPath := absPath + ".tmp-write" + ext - args := []string{"-hide_banner", "-loglevel", "error", "-y", + + // Shared args; each attempt appends its own stream mapping. + base := []string{"-hide_banner", "-loglevel", "error", "-y", "-i", absPath, - "-map", "0", "-map_metadata", "0", "-map_chapters", "0", - "-c", "copy", + "-map_metadata", "0", "-map_chapters", "0", "-c", "copy", } for _, kv := range audioTagArgs(item, ext) { - args = append(args, "-metadata", kv[0]+"="+kv[1]) - } - args = append(args, tmpPath) - - if out, err := exec.Command("ffmpeg", args...).CombinedOutput(); err != nil { - os.Remove(tmpPath) - return "", fmt.Errorf("ffmpeg tag rewrite failed: %s", strings.TrimSpace(string(out))) + base = append(base, "-metadata", kv[0]+"="+kv[1]) } - // Validation gate: the rewritten container must still hold the - // same audio (duration within 1s or 0.5%, whichever is looser). - outDuration := probeDurationSeconds(tmpPath) - if outDuration <= 0 { - os.Remove(tmpPath) - return "", fmt.Errorf("rewritten audio failed validation (ffprobe read no duration); original untouched") + // Stream maps, best fidelity first. + streamMaps := [][]string{ + {"-map", "0"}, // every stream (covers, chapters-as-streams) + // Audio + attached pictures; drop text/subtitle/data tracks — + // the ipod muxer refuses mov_text-style streams outright. + {"-map", "0:a", "-map", "0:t?", "-dn", "-sn"}, } - if srcDuration > 0 { - tolerance := math.Max(1.0, srcDuration*0.005) - if math.Abs(outDuration-srcDuration) > tolerance { + + var lastErr error + for i, streamMap := range streamMaps { + args := append(append([]string{}, base...), streamMap...) + args = append(args, tmpPath) + if out, err := exec.Command("ffmpeg", args...).CombinedOutput(); err != nil { os.Remove(tmpPath) - return "", fmt.Errorf("rewritten audio failed validation (duration %.2fs → %.2fs); original untouched", srcDuration, outDuration) + lastErr = fmt.Errorf("ffmpeg tag rewrite failed (attempt %d): %s", i+1, strings.TrimSpace(string(out))) + continue } - } - if err := os.Rename(tmpPath, absPath); err != nil { - os.Remove(tmpPath) - return "", fmt.Errorf("failed to replace audio file: %w", err) + // Validation gate: the rewritten container must still hold the + // same audio (duration within 1s or 0.5%, whichever is looser). + outDuration := probeDurationSeconds(tmpPath) + if outDuration <= 0 { + os.Remove(tmpPath) + lastErr = fmt.Errorf("rewritten audio failed validation (ffprobe read no duration)") + continue + } + if srcDuration > 0 { + tolerance := math.Max(1.0, srcDuration*0.005) + if math.Abs(outDuration-srcDuration) > tolerance { + os.Remove(tmpPath) + lastErr = fmt.Errorf("rewritten audio failed validation (duration %.2fs → %.2fs)", srcDuration, outDuration) + continue + } + } + + if err := os.Rename(tmpPath, absPath); err != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("failed to replace audio file: %w", err) + } + return absPath, nil } - return absPath, nil + return "", lastErr } // ---- CBZ ComicInfo.xml (5c) ---- diff --git a/internal/services/metadata_writer_test.go b/internal/services/metadata_writer_test.go index 369966a2..6e0938ae 100644 --- a/internal/services/metadata_writer_test.go +++ b/internal/services/metadata_writer_test.go @@ -330,6 +330,22 @@ func TestBuildComicInfoXMLSkipsFractionalNumber(t *testing.T) { assert.NotContains(t, buildComicInfoXML(item), "") } +// 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 diff --git a/templates/book_detail.templ b/templates/book_detail.templ index 8b3181d1..fb2e5ac6 100644 --- a/templates/book_detail.templ +++ b/templates/book_detail.templ @@ -603,6 +603,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb @AnnotationsBanner(book) if user.Role == "admin" { @MetadataEditorModal(book) + @WriteToFileConfirmDialog(book) } diff --git a/templates/book_detail_modals.templ b/templates/book_detail_modals.templ index 26204ef4..68f9dab1 100644 --- a/templates/book_detail_modals.templ +++ b/templates/book_detail_modals.templ @@ -716,3 +716,52 @@ templ MetadataEditorModal(book handlers.MediaDetail) { } + +// WriteToFileConfirmDialog is the in-app replacement for the browser +// confirm() on the metadata editor's "Write to file" action (CSP and +// UX: no native dialogs). The target-specific copy comes from the +// bookDetail Alpine component (same scope as the editor modal). +templ WriteToFileConfirmDialog(book handlers.MediaDetail) { +
+
+
+ + @Icon("save", "h-5 w-5") + +

Write metadata to file?

+
+

+

+ Your pending edits are saved first, then the file is rewritten and re-scanned. +

+
+ + +
+
+
+} diff --git a/templates/book_detail_modals_templ.go b/templates/book_detail_modals_templ.go index e5643d3f..053c56d8 100644 --- a/templates/book_detail_modals_templ.go +++ b/templates/book_detail_modals_templ.go @@ -1547,4 +1547,53 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component { }) } +// WriteToFileConfirmDialog is the in-app replacement for the browser +// confirm() on the metadata editor's "Write to file" action (CSP and +// UX: no native dialogs). The target-specific copy comes from the +// bookDetail Alpine component (same scope as the editor modal). +func WriteToFileConfirmDialog(book handlers.MediaDetail) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var81 := templ.GetChildren(ctx) + if templ_7745c5c3_Var81 == nil { + templ_7745c5c3_Var81 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("save", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "

Write metadata to file?

Your pending edits are saved first, then the file is rewritten and re-scanned.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + var _ = templruntime.GeneratedTemplate diff --git a/templates/book_detail_templ.go b/templates/book_detail_templ.go index 9521c387..a19d8022 100644 --- a/templates/book_detail_templ.go +++ b/templates/book_detail_templ.go @@ -1823,8 +1823,16 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = WriteToFileConfirmDialog(book).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "

Edit Review

Markdown — renders like notes. Saving an empty review deletes it.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "

Edit Review

Markdown — renders like notes. Saving an empty review deletes it.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1832,7 +1840,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombs if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts index cf6ba9c3..b5d59f2a 100644 --- a/web/src/book-detail.ts +++ b/web/src/book-detail.ts @@ -128,6 +128,7 @@ interface MetadataEditorState { rescanning: boolean; resetting: boolean; writingFile: boolean; + writeConfirmOpen: boolean; fileWritePath: string; userRating: number; ratingHover: number; @@ -143,7 +144,10 @@ interface MetadataEditorState { saveMetadata(): Promise; rescanBook(): Promise; resetMetadata(): Promise; - writeMetadataToFile(): Promise; + writeMetadataToFile(): void; + writeConfirmMessage(): string; + performWriteToFile(): Promise; + persistMetadata(): Promise; metadataWriteTarget(): string; metadataWriteHint(): string; starFill(i: number): string; @@ -191,6 +195,7 @@ Alpine.data("bookDetail", () => { // pattern as the editor tags above); the capability mirrors // services.MetadataWriteTargetFor. writingFile: false, + writeConfirmOpen: false, fileWritePath: (() => { const modal = document.getElementById("metadata-editor-modal"); return modal?.getAttribute("data-write-file-path") || ""; @@ -810,7 +815,22 @@ Alpine.data("bookDetail", () => { async saveMetadata() { if (this.saving) return; this.saving = true; + try { + if (await this.persistMetadata()) { + hideMetadataEditor(); + showToast("Metadata saved successfully", "success"); + setTimeout(() => window.location.reload(), 500); + } + } finally { + this.saving = false; + } + }, + // persistMetadata PUTs the editor's current fields (with cover, when + // one was staged) — no toast, no reload. saveMetadata and the + // write-to-file flow share it, so "Write to file" always persists + // the user's PENDING edits before touching the file. + async persistMetadata(): Promise { try { const mediaId = getMediaId(); const formData = collectFormData(this.editorTags); @@ -854,14 +874,10 @@ Alpine.data("bookDetail", () => { throw new Error(err.error || "Failed to save metadata"); } } - - hideMetadataEditor(); - showToast("Metadata saved successfully", "success"); - setTimeout(() => window.location.reload(), 500); + return true; } catch (e) { showToast(e instanceof Error ? e.message : "Failed to save metadata", "error"); - } finally { - this.saving = false; + return false; } }, @@ -915,19 +931,35 @@ Alpine.data("bookDetail", () => { async writeMetadataToFile() { if (this.writingFile) return; - const target = this.metadataWriteTarget(); - const confirmMsg = - target === "embedded-opf" - ? "Rewrite the metadata inside this EPUB with the current saved values? The original file will be modified (the SHA-256 changes, so device syncs re-key this book)." - : target === "audio-tags" - ? "Rewrite this audiobook's embedded tags with the current saved values? The audio streams are copied untouched, but the file's checksum changes, so device syncs re-key this book." - : "Write a metadata sidecar next to this file with the current saved values? The media file itself is not modified."; - if (!window.confirm(confirmMsg)) { - return; + // In-app dialog (not window.confirm): shows the target-specific + // copy; the dialog's confirm button calls performWriteToFile(). + this.writeConfirmOpen = true; + }, + + writeConfirmMessage(): string { + switch (this.metadataWriteTarget()) { + case "embedded-opf": + return "The metadata inside this EPUB will be rewritten with your current edits. The file's checksum changes, so device syncs re-key this book."; + case "audio-tags": + return "This audiobook's embedded tags will be rewritten with your current edits. Audio streams are copied untouched, but the file's checksum changes, so device syncs re-key this book."; + case "comicinfo": + return "A ComicInfo.xml with your current edits will be written inside this comic archive. The page images are not modified."; + default: + return "A Calibre-style metadata sidecar with your current edits will be written next to this file. The media file itself is not modified."; } + }, + + async performWriteToFile() { + if (this.writingFile) return; + this.writeConfirmOpen = false; this.writingFile = true; - const mediaId = getMediaId(); try { + // Save the pending editor edits FIRST: the endpoint writes the + // item's DB values, so what the user sees in the modal must be + // in the database before the file is rewritten. + if (!(await this.persistMetadata())) return; + + const mediaId = getMediaId(); const resp = await fetch(`/api/media-items/${mediaId}/write-metadata`, { method: "POST", headers: { Authorization: getAuthHeader() }, @@ -956,6 +988,9 @@ Alpine.data("bookDetail", () => { : "Metadata sidecar written and re-extracted"; showToast(label, "success"); } + // Same page refresh as Save: the editor closes and the page + // reflects the written + re-extracted state. + hideMetadataEditor(); setTimeout(() => window.location.reload(), 800); } catch (e) { showToast(e instanceof Error ? e.message : "Failed to write metadata to file", "error");