fix(scanner): re-extraction is write-after-success — no NULL audio_metadata
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.
This commit is contained in:
1 parent
970beee4b8
commit
80bfc1a77b
3 files changed
+64
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user