fix(metadata): m4b text-track fallback; in-app confirm; save-first write

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.
This commit is contained in:
John O'Keefe committed 2026-10-27 19:05:23 -04:00
1 parent 96c1228805
commit fb1645c90c
8 files changed
+334 -90

No files matched your search

+124 -58
View File
@@ -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 <basename>.opf", func(t *testing.T) {
@@ -221,33 +256,54 @@ 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())
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))
// 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",
"-t", "2", "-c:a", tc.audioCodec,
"-metadata", "title=Generated Fixture",
"-metadata", "album=Generated Fixture",
abs)
out, err := gen.CombinedOutput()
require.NoError(t, err, "ffmpeg fixture generation failed: %s", string(out))
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,
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,
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).
// 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",
@@ -260,11 +316,10 @@ func TestMetadataWriteToFile(t *testing.T) {
})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "audio-tags", body["metadata_target"])
require.Equal(t, http.StatusOK, code, "%s write", tc.ext)
assert.Equal(t, "audio-tags", body["metadata_target"], tc.ext)
// The container now carries the round-trip keys (probe with the
// same tool the scanner uses).
// ---- 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()
@@ -278,36 +333,38 @@ func TestMetadataWriteToFile(t *testing.T) {
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")
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", "honest fields_written")
assert.NotContains(t, writeMap["fields_written"], "subtitle", "%s: honest fields_written", tc.ext)
}
// Still a valid, same-length audio file after the rewrite.
dur := func() float64 {
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
// 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)
v, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
dur, err := strconv.ParseFloat(strings.TrimSpace(string(durOut)), 64)
require.NoError(t, err)
return v
}
assert.Greater(t, dur(), 1.5, "audio survived the rewrite")
assert.Greater(t, dur, 1.5, "%s: audio survived the rewrite", tc.ext)
// The inline rescan re-read the tags: DB title stays the written one.
// 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)
assert.Equal(t, "Audio New Title", title, tc.ext)
})
}
})
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, "<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>")
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.
+33 -13
View File
@@ -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,19 +580,32 @@ 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])
base = append(base, "-metadata", kv[0]+"="+kv[1])
}
args = append(args, tmpPath)
// 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"},
}
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("ffmpeg tag rewrite failed: %s", strings.TrimSpace(string(out)))
lastErr = fmt.Errorf("ffmpeg tag rewrite failed (attempt %d): %s", i+1, strings.TrimSpace(string(out)))
continue
}
// Validation gate: the rewritten container must still hold the
@@ -597,13 +613,15 @@ func (w *MetadataWriter) writeAudioTags(absPath string, item *database.MediaItem
outDuration := probeDurationSeconds(tmpPath)
if outDuration <= 0 {
os.Remove(tmpPath)
return "", fmt.Errorf("rewritten audio failed validation (ffprobe read no duration); original untouched")
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)
return "", fmt.Errorf("rewritten audio failed validation (duration %.2fs → %.2fs); original untouched", srcDuration, outDuration)
lastErr = fmt.Errorf("rewritten audio failed validation (duration %.2fs → %.2fs)", srcDuration, outDuration)
continue
}
}
@@ -612,6 +630,8 @@ func (w *MetadataWriter) writeAudioTags(absPath string, item *database.MediaItem
return "", fmt.Errorf("failed to replace audio file: %w", err)
}
return absPath, nil
}
return "", lastErr
}
// ---- CBZ ComicInfo.xml (5c) ----
+16
View File
@@ -330,6 +330,22 @@ func TestBuildComicInfoXMLSkipsFractionalNumber(t *testing.T) {
assert.NotContains(t, buildComicInfoXML(item), "<Number>")
}
// 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
+1
View File
@@ -603,6 +603,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
@AnnotationsBanner(book)
if user.Role == "admin" {
@MetadataEditorModal(book)
@WriteToFileConfirmDialog(book)
}
<!-- Review editor: plain textarea over the RAW markdown (what
storage and sync carry); rendering happens only on display. -->
+49
View File
@@ -716,3 +716,52 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</div>
</div>
}
// 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) {
<div
id="write-confirm-dialog"
x-show="writeConfirmOpen"
x-cloak
x-transition.opacity
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
>
<div class="card w-full max-w-md p-6" style="box-shadow: var(--shadow-pop);">
<div class="flex items-center gap-3 mb-4">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("save", "h-5 w-5")
</span>
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Write metadata to file?</h3>
</div>
<p class="text-sm mb-2" style="color: var(--text-secondary)" x-text="writeConfirmMessage()"></p>
<p class="text-xs mb-6" style="color: var(--text-secondary)">
Your pending edits are saved first, then the file is rewritten and re-scanned.
</p>
<div class="flex justify-end gap-3">
<button
type="button"
@click="writeConfirmOpen = false"
class="btn btn-secondary"
>
Cancel
</button>
<button
type="button"
@click="performWriteToFile()"
:disabled="writingFile"
class="btn btn-primary"
>
<span x-show="!writingFile" class="inline-flex items-center gap-2">@Icon("save", "h-4 w-4")<span>Save &amp; write</span></span>
<svg x-show="writingFile" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
</div>
</div>
</div>
}
+49
View File
@@ -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, "<div id=\"write-confirm-dialog\" x-show=\"writeConfirmOpen\" x-cloak x-transition.opacity class=\"fixed inset-0 z-[60] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-md p-6\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
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, "</span><h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Write metadata to file?</h3></div><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\" x-text=\"writeConfirmMessage()\"></p><p class=\"text-xs mb-6\" style=\"color: var(--text-secondary)\">Your pending edits are saved first, then the file is rewritten and re-scanned.</p><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"writeConfirmOpen = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"performWriteToFile()\" :disabled=\"writingFile\" class=\"btn btn-primary\"><span x-show=\"!writingFile\" class=\"inline-flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("save", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<span>Save &amp; write</span></span> <svg x-show=\"writingFile\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+10 -2
View File
@@ -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 = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "<!-- Review editor: plain textarea over the RAW markdown (what\n\t\t\t storage and sync carry); rendering happens only on display. --><div x-show=\"reviewOpen\" x-cloak class=\"fixed inset-0 flex items-center justify-center z-50 p-4\" style=\"background-color: var(--surface-overlay);\" @keydown.escape.window=\"reviewOpen = false\"><div class=\"absolute inset-0\" @click=\"reviewOpen = false\"></div><div class=\"relative card p-6 w-full max-w-2xl\"><h3 class=\"text-lg font-semibold mb-3\" style=\"color: var(--text-primary)\">Edit Review</h3><textarea x-model=\"reviewDraft\" rows=\"12\" class=\"w-full rounded-xl border p-3 text-sm font-mono resize-y\" style=\"background-color: var(--bg-primary); border-color: var(--border); color: var(--text-primary);\" placeholder=\"Markdown supported — **bold**, lists, footnotes…\"></textarea><p class=\"mt-1 text-xs\" style=\"color: var(--text-secondary)\">Markdown — renders like notes. Saving an empty review deletes it.</p><div class=\"mt-4 flex items-center gap-2\"><button type=\"button\" @click=\"saveReview()\" :disabled=\"reviewSaving\" class=\"btn btn-primary\"><span x-show=\"!reviewSaving\">Save review</span> <span x-show=\"reviewSaving\">Saving…</span></button> <button type=\"button\" @click=\"deleteReview()\" :disabled=\"reviewSaving || !hasReview\" class=\"btn btn-secondary\" style=\"color: var(--status-danger, #e5484d);\">Delete</button> <button type=\"button\" @click=\"reviewOpen = false\" class=\"btn btn-secondary ml-auto\">Cancel</button></div></div></div>")
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, 206, "<!-- Review editor: plain textarea over the RAW markdown (what\n\t\t\t storage and sync carry); rendering happens only on display. --><div x-show=\"reviewOpen\" x-cloak class=\"fixed inset-0 flex items-center justify-center z-50 p-4\" style=\"background-color: var(--surface-overlay);\" @keydown.escape.window=\"reviewOpen = false\"><div class=\"absolute inset-0\" @click=\"reviewOpen = false\"></div><div class=\"relative card p-6 w-full max-w-2xl\"><h3 class=\"text-lg font-semibold mb-3\" style=\"color: var(--text-primary)\">Edit Review</h3><textarea x-model=\"reviewDraft\" rows=\"12\" class=\"w-full rounded-xl border p-3 text-sm font-mono resize-y\" style=\"background-color: var(--bg-primary); border-color: var(--border); color: var(--text-primary);\" placeholder=\"Markdown supported — **bold**, lists, footnotes…\"></textarea><p class=\"mt-1 text-xs\" style=\"color: var(--text-secondary)\">Markdown — renders like notes. Saving an empty review deletes it.</p><div class=\"mt-4 flex items-center gap-2\"><button type=\"button\" @click=\"saveReview()\" :disabled=\"reviewSaving\" class=\"btn btn-primary\"><span x-show=\"!reviewSaving\">Save review</span> <span x-show=\"reviewSaving\">Saving…</span></button> <button type=\"button\" @click=\"deleteReview()\" :disabled=\"reviewSaving || !hasReview\" class=\"btn btn-secondary\" style=\"color: var(--status-danger, #e5484d);\">Delete</button> <button type=\"button\" @click=\"reviewOpen = false\" class=\"btn btn-secondary ml-auto\">Cancel</button></div></div></div>")
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, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+52 -17
View File
@@ -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<void>;
rescanBook(): Promise<void>;
resetMetadata(): Promise<void>;
writeMetadataToFile(): Promise<void>;
writeMetadataToFile(): void;
writeConfirmMessage(): string;
performWriteToFile(): Promise<void>;
persistMetadata(): Promise<boolean>;
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<boolean> {
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");