From 80bfc1a77bbc0075fa93243975e5fdfd8d3514b9 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 21 Oct 2026 20:22:57 -0400 Subject: [PATCH] =?UTF-8?q?fix(scanner):=20re-extraction=20is=20write-afte?= =?UTF-8?q?r-success=20=E2=80=94=20no=20NULL=20audio=5Fmetadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-10-21 wipe: a re-extraction pass NULLed audio_metadata on the m4b audiobooks — every affected book dropped its chapters, track manifest and duration and displayed as 1 second long (the reader also lost its manifest, since single-file books synthesize it from the payload). Guards on both writers of that column: - updateMediaItemWithMetadata (generic full-row pass): when the new extraction carries no audio payload, keep existing.AudioMetadata. Single-file m4b books route here, and a failed/absent extraction leaves AudioMeta nil. - updateAudioBookItem (focused payload write): an empty payload (no tracks, no chapters, no duration) over a populated row is a failed extraction — refuse the write with a loud warning and let the next scan retry. audioPayloadIsEmpty() pins the empty-payload predicate (unit-tested): the column is only ever replaced by fresh data from a real audio extraction, never by absence of one. --- internal/services/media_scanner.go | 14 +++++++- .../media_scanner_audio_guard_test.go | 33 +++++++++++++++++++ .../services/media_scanner_audio_multifile.go | 18 ++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 internal/services/media_scanner_audio_guard_test.go diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 1828beee..762ea16f 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -4049,6 +4049,18 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing if metadata.AlternateInfo != "" { alternateInfoBytes = []byte(metadata.AlternateInfo) } + // A re-extraction that produced no audio payload must never wipe + // scan-derived audio_metadata (tracks/chapters/duration): the + // ev-gated generic pass runs for multi-file books too, where + // extractMetadata leaves AudioMeta nil — writing NULL here deleted + // every folder book's payload and broke playback (2026-10-21 + // incident: all multi-file audiobooks showed as 1 second long). The + // audio chain (UpdateMediaItemAudioBookPayload) owns this column; + // without fresh data from THAT chain, keep what the row has. + audioMetadata := marshalAudioMetadata(metadata.AudioMeta) + if audioMetadata == nil { + audioMetadata = existing.AudioMetadata + } params := database.UpdateMediaItemParams{ ID: existing.ID, Title: metadata.Title, @@ -4089,7 +4101,7 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing AlternateInfo: alternateInfoBytes, ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""}, Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""}, - AudioMetadata: marshalAudioMetadata(metadata.AudioMeta), + AudioMetadata: audioMetadata, } // Keep user-customized fields, and keep the override set itself intact. diff --git a/internal/services/media_scanner_audio_guard_test.go b/internal/services/media_scanner_audio_guard_test.go new file mode 100644 index 00000000..54ed7e90 --- /dev/null +++ b/internal/services/media_scanner_audio_guard_test.go @@ -0,0 +1,33 @@ +package services + +import ( + "testing" +) + +// TestAudioPayloadIsEmpty pins the write-after-success guard on +// updateAudioBookItem (2026-10-21 incident: multi-file audiobooks +// surfaced as 1 second long because a failed re-extraction overwrote a +// populated audio_metadata). An empty payload — nil, or no tracks, no +// chapters, no duration — is the signature of a failed extraction and +// must never replace existing audio data. +func TestAudioPayloadIsEmpty(t *testing.T) { + require := func(cond bool, msg string) { + if !cond { + t.Fatal(msg) + } + } + + require(audioPayloadIsEmpty(nil), "nil payload must read as empty") + require(audioPayloadIsEmpty(&AudioMetadataPayload{}), "zero-value payload must read as empty") + require(audioPayloadIsEmpty(&AudioMetadataPayload{DurationSeconds: 0}), "zero-duration payload must read as empty") + + // A real book always carries at least one of the audio facts. + require(!audioPayloadIsEmpty(&AudioMetadataPayload{DurationSeconds: 302.6}), + "duration-only payload (single-track book) is not empty") + require(!audioPayloadIsEmpty(&AudioMetadataPayload{ + Tracks: []AudioTrack{{Index: 0, Path: "01.mp3"}}, + }), "track manifest is not empty") + require(!audioPayloadIsEmpty(&AudioMetadataPayload{ + Chapters: []AudioChapter{{Index: 0, Title: "Chapter 1"}}, + }), "chapters are not empty") +} diff --git a/internal/services/media_scanner_audio_multifile.go b/internal/services/media_scanner_audio_multifile.go index 0417a1a2..a32c00b5 100644 --- a/internal/services/media_scanner_audio_multifile.go +++ b/internal/services/media_scanner_audio_multifile.go @@ -1015,6 +1015,16 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin // rescan saw the track set change (or the admin forced a rescan). Only // scan-derived fields move — editorial columns keep admin edits. func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing database.MediaItems, payload *AudioMetadataPayload, mimeType string, totalSize int64, chapterCount int) error { + // Write-after-success discipline (2026-10-21 incident: multi-file + // audiobooks surfaced as 1 second long because a re-extraction wrote + // a NULL/empty audio_metadata over a populated one). An empty payload + // is a FAILED extraction, not a book with no audio: when the row + // already carries audio data, keep it and skip the write — the next + // scan retries, and the reader keeps its manifest in the meantime. + if audioPayloadIsEmpty(payload) && existing.AudioMetadata != nil { + fmt.Printf("[AUDIO] WARNING: re-extraction of '%s' produced an empty audio payload; keeping the existing audio_metadata\n", existing.Title) + return nil + } if _, err := s.db.UpdateMediaItemAudioBookPayload(ctx, database.UpdateMediaItemAudioBookPayloadParams{ ID: existing.ID, FileSize: pgtype.Int8{Int64: totalSize, Valid: true}, @@ -1028,6 +1038,14 @@ func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing databas return nil } +// audioPayloadIsEmpty reports whether a payload carries no audio facts +// at all — no tracks, no chapters, no duration. Such a payload is the +// signature of a failed/absent extraction, never of a real book. +func audioPayloadIsEmpty(payload *AudioMetadataPayload) bool { + return payload == nil || + (len(payload.Tracks) == 0 && len(payload.Chapters) == 0 && payload.DurationSeconds <= 0) +} + // multifileSidecarChanged runs the manifest ladder for a folder/zip book // (faceted plan Phase 6): a size-stable multi-file book is changed by // EDITING its sidecars — the track-count/size fingerprint never saw it,