diff --git a/internal/scanner/audiobook_scan.go b/internal/scanner/audiobook_scan.go index 9d536674..ddfa1682 100644 --- a/internal/scanner/audiobook_scan.go +++ b/internal/scanner/audiobook_scan.go @@ -17,6 +17,7 @@ import ( "github.com/Silo-Server/silo-server/internal/idgen" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/titleutil" + "github.com/jackc/pgx/v5" ) // audiobookDiskFile is the on-disk projection used by audiobookFolderUnchanged. @@ -689,33 +690,64 @@ func (s *Scanner) upsertAudiobookSeries(ctx context.Context, contentID string, b if s.fileRepo == nil { return fmt.Errorf("fileRepo not configured on Scanner") } - name := strings.TrimSpace(book.Series) - if name == "" { - _, err := s.fileRepo.Pool().Exec(ctx, - `DELETE FROM audiobook_series WHERE content_id = $1`, contentID) - if err != nil { - return fmt.Errorf("delete audiobook_series row: %w", err) + desiredName := strings.TrimSpace(book.Series) + desiredIdx := parseSeriesIndex(book.SeriesPosition) + + // Read current row. + var currentName *string + var currentIdx *float64 + err := s.fileRepo.Pool().QueryRow(ctx, ` + SELECT series_name, series_index FROM audiobook_series WHERE content_id = $1 + `, contentID).Scan(¤tName, ¤tIdx) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("query audiobook_series: %w", err) + } + + if desiredName == "" { + if currentName == nil { + return nil // already absent + } + if _, delErr := s.fileRepo.Pool().Exec(ctx, + `DELETE FROM audiobook_series WHERE content_id = $1`, contentID); delErr != nil { + return fmt.Errorf("delete audiobook_series row: %w", delErr) } return nil } - var idx any - if v := parseSeriesIndex(book.SeriesPosition); v != nil { - idx = *v + + if currentName != nil && *currentName == desiredName && floatPtrEqual(currentIdx, desiredIdx) { + return nil // identical row, skip the write } - _, err := s.fileRepo.Pool().Exec(ctx, ` + + var idx any + if desiredIdx != nil { + idx = *desiredIdx + } + if _, err := s.fileRepo.Pool().Exec(ctx, ` INSERT INTO audiobook_series (content_id, series_name, series_index, updated_at) VALUES ($1, $2, $3, NOW()) ON CONFLICT (content_id) DO UPDATE SET series_name = EXCLUDED.series_name, series_index = EXCLUDED.series_index, updated_at = NOW() - `, contentID, name, idx) - if err != nil { + `, contentID, desiredName, idx); err != nil { return fmt.Errorf("upsert audiobook_series row: %w", err) } return nil } +// floatPtrEqual returns true when two *float64 values represent the same +// state — both nil, or both non-nil and equal. Used to skip audiobook_series +// re-upserts when only the series_index needs to be NULL=NULL compared. +func floatPtrEqual(a, b *float64) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + // parseSeriesIndex extracts a leading numeric value from a freeform tag // like "5", "1.5", "2 of 8", or "1a". Returns nil when no leading number // is present so the audiobook_series.series_index column stays NULL rather diff --git a/internal/scanner/audiobook_test.go b/internal/scanner/audiobook_test.go index 189b0b77..dca570cd 100644 --- a/internal/scanner/audiobook_test.go +++ b/internal/scanner/audiobook_test.go @@ -232,3 +232,27 @@ func TestAudiobookPeopleCreditsEqual(t *testing.T) { }) } } + +func TestFloatPtrEqual(t *testing.T) { + a := 1.5 + b := 1.5 + c := 2.0 + cases := []struct { + name string + x, y *float64 + want bool + }{ + {"both nil", nil, nil, true}, + {"left nil", nil, &a, false}, + {"right nil", &a, nil, false}, + {"equal", &a, &b, true}, + {"unequal", &a, &c, false}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := floatPtrEqual(tt.x, tt.y); got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +}