fix(media-items): normalize NOT NULL narrators on every write path

A2 made narrators/narrators_search NOT NULL DEFAULT '{}' (86f0af6), but an
explicit NULL in an INSERT/UPDATE column list bypasses the column default
— the API media-create path (POST /api/media-items without narrators)
500'd on every call, taking the whole integration suite down with it
(ISBN tests, capture seeds, all direct-DB seeds).

- set_library_type_name() trigger now coalesces NULL narrators/
  narrators_search to '{}' at the row level, and its update trigger
  widens from BEFORE UPDATE OF library_id to BEFORE UPDATE so every
  write path is covered
- defense in depth at the callers: handler create/update normalize nil
  to empty slices (normalizeNarrators), scanner seeds the same
  (nilToEmpty) for files that carry no narrator credits
- analytics device-usage tests move to the dedicated per-test user
  (the shared admin's device list accrues live rows mid-suite — the
  documented testuser flapping class)
This commit is contained in:
John O'Keefe committed 2026-10-16 10:14:12 -04:00
1 parent 73ae1789c5
commit a222ef5c27
5 files changed
+56 -13

No files matched your search

+11
View File
@@ -65,6 +65,17 @@ func parseDate(dateStr string) time.Time {
return time.Time{}
}
// normalizeNarrators guards the NOT NULL narrators columns (A2): a Go nil
// slice becomes an explicit NULL in INSERT/UPDATE params and 23502s — the
// column DEFAULT never fires for explicit NULLs. Omitted narrators mean
// "none known", which is an empty array, never NULL.
func normalizeNarrators(narrators []string) []string {
if narrators == nil {
return []string{}
}
return narrators
}
func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.ConnectionManager, queueProcessor *wsync.SyncQueueProcessor, cfg *config.Config) *Handler {
return NewHandler(db, connManager, queueProcessor, cfg)
}
+6 -4
View File
@@ -1414,7 +1414,8 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
narratorsSearch := utils.NormalizeNarratorsSearch(req.Narrators)
narrators := normalizeNarrators(req.Narrators)
narratorsSearch := utils.NormalizeNarratorsSearch(narrators)
// Validate and normalize ISBN
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
@@ -1464,7 +1465,7 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: req.Tags,
TagsSearch: tagsSearch,
Narrators: req.Narrators,
Narrators: narrators,
NarratorsSearch: narratorsSearch,
Abridged: req.Abridged,
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
@@ -1532,7 +1533,8 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
narratorsSearch := utils.NormalizeNarratorsSearch(req.Narrators)
narrators := normalizeNarrators(req.Narrators)
narratorsSearch := utils.NormalizeNarratorsSearch(narrators)
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
if err != nil && req.ISBN != "" {
@@ -1586,7 +1588,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: req.Tags,
TagsSearch: tagsSearch,
Narrators: req.Narrators,
Narrators: narrators,
NarratorsSearch: narratorsSearch,
Abridged: req.Abridged,
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
+13 -2
View File
@@ -169,6 +169,17 @@ type CalibreOPFMetadata struct {
// watcher (and thus can never panic on EMFILE/ENOSPC). This fixes the
// fd/inotify-watch leak where every scan job created a watcher that was
// never closed.
// nilToEmpty guards the NOT NULL narrators columns (A2): a Go nil slice
// becomes an explicit NULL in INSERT/UPDATE params and 23502s — the
// column DEFAULT never fires for explicit NULLs. "No narrators known"
// is an empty array, never NULL.
func nilToEmpty(slice []string) []string {
if slice == nil {
return []string{}
}
return slice
}
func NewMediaScanner(db *database.Queries) *MediaScanner {
return &MediaScanner{
db: db,
@@ -947,7 +958,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Contributors: metadata.Contributors,
ContributorsSearch: contributorsSearch,
Narrators: metadata.Narrators,
Narrators: nilToEmpty(metadata.Narrators),
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
Abridged: metadata.Abridged,
Tags: metadata.Tags,
@@ -3210,7 +3221,7 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
CoverImagePath: pgtype.Text{String: s.getRelativePath(metadata.CoverPath), Valid: metadata.CoverPath != ""},
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Narrators: metadata.Narrators,
Narrators: nilToEmpty(metadata.Narrators),
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
Abridged: metadata.Abridged,
Tags: metadata.Tags,