From 443445f0feee6e295ad2d8895f749950fb871069 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:06:01 -0400 Subject: [PATCH] fix(catalog): make media_items scalar columns NOT NULL to stop NULL-scan crashes (#228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit media_items had many columns the Go model (models.MediaItem) declares as non-pointer string/int fields, but migration 001 left them nullable. Five scanners (catalog item_repo, catalog browse, jellycompat, sections, and the catalog API handler) read these straight into the non-pointer fields, so a NULL row panics with "cannot scan NULL into *string" (or *int). item_repo papers over a subset (poster/backdrop/logo/metadata paths) with COALESCE in its SELECT, but the other four scanners read the same columns raw and crash; sort_title/original_title/etc. are not coalesced anywhere. No writer stores a meaningful NULL (every insert/upsert passes the Go field, '' or 0 at worst) and all sort/filter SQL already collapses NULL and '' (e.g. COALESCE(NULLIF(BTRIM(sort_title), ''), title); "poster_path IS NULL OR poster_path = ''"). Enforce the invariant the code already assumes at the schema level rather than scattering COALESCE across every current and future scanner. Mirrors what later migrations already did for original_language, show_status, default_metadata_language, and the *_source_path columns (all NOT NULL DEFAULT ''). - Migration: backfill existing NULLs, then NOT NULL DEFAULT '' on 15 text columns (sort_title, original_title, content_rating, overview, tagline, imdb_id/tmdb_id/tvdb_id, poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path, metadata_s3_path, metadata_etag) and NOT NULL DEFAULT 0 on year/runtime. - item_repo: SetLocalPoster and UpdateArtworkIfSourceMatches now store '' for empty thumbhashes (was NULLIF($,'')) — the only deliberate NULL writers — matching the upsert path. Co-authored-by: Claude Opus 4.8 --- internal/catalog/item_repo.go | 6 +- ...132_media_items_nonnull_scalar_columns.sql | 89 +++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 migrations/sql/20260626190132_media_items_nonnull_scalar_columns.sql diff --git a/internal/catalog/item_repo.go b/internal/catalog/item_repo.go index 739c901d..28f963c5 100644 --- a/internal/catalog/item_repo.go +++ b/internal/catalog/item_repo.go @@ -90,7 +90,7 @@ func (r *ItemRepository) SetLocalPoster(ctx context.Context, contentID, posterPa tag, err := r.pool.Exec(ctx, ` UPDATE media_items SET poster_path = $2, - poster_thumbhash = NULLIF($3, ''), + poster_thumbhash = $3, updated_at = NOW() WHERE content_id = $1 AND (poster_path IS NULL OR poster_path = '' OR poster_path LIKE $4 || '%') @@ -1607,7 +1607,7 @@ func (r *ItemRepository) UpdateArtworkIfSourceMatches(ctx context.Context, conte UPDATE media_items SET poster_path = $3, poster_source_path = $2, - poster_thumbhash = NULLIF($4, ''), + poster_thumbhash = $4, updated_at = NOW() WHERE content_id = $1 AND poster_source_path = $2` @@ -1617,7 +1617,7 @@ func (r *ItemRepository) UpdateArtworkIfSourceMatches(ctx context.Context, conte UPDATE media_items SET backdrop_path = $3, backdrop_source_path = $2, - backdrop_thumbhash = NULLIF($4, ''), + backdrop_thumbhash = $4, updated_at = NOW() WHERE content_id = $1 AND backdrop_source_path = $2` diff --git a/migrations/sql/20260626190132_media_items_nonnull_scalar_columns.sql b/migrations/sql/20260626190132_media_items_nonnull_scalar_columns.sql new file mode 100644 index 00000000..4ae6a310 --- /dev/null +++ b/migrations/sql/20260626190132_media_items_nonnull_scalar_columns.sql @@ -0,0 +1,89 @@ +-- +goose Up +-- media_items has many columns that the Go model (models.MediaItem) declares as +-- non-pointer string/int fields, yet migration 001 left them nullable. Every +-- scanner — catalog item_repo, catalog browse, jellycompat, sections, and the +-- catalog API handler — scans these straight into the non-pointer fields, so a +-- NULL row panics with "cannot scan NULL into *string" (or *int). item_repo +-- papers over a subset (poster/backdrop/logo/metadata paths) with COALESCE in +-- its SELECT list, but the other four scanners read the same columns raw and +-- crash; sort_title/original_title/etc. are not coalesced anywhere. +-- +-- No writer stores a meaningful NULL: every insert/upsert passes the Go field +-- ('' or 0 at worst), and all sort/filter SQL already collapses NULL and '' +-- (COALESCE(NULLIF(BTRIM(sort_title), ''), title); "poster_path IS NULL OR +-- poster_path = ''"; etc.). The only deliberate NULLs are poster_thumbhash / +-- backdrop_thumbhash written via NULLIF($, '') in item_repo; those writers are +-- updated in the same change to store '' directly. NULL rows otherwise arise +-- only from raw SQL inserts and legacy data. +-- +-- Enforce the invariant the code already assumes so the whole class of NULL-scan +-- crash is fixed in one place rather than scattering COALESCE across every +-- current and future scanner. This mirrors what later migrations already did for +-- original_language, show_status, default_metadata_language, and the *_source_path +-- columns (all NOT NULL DEFAULT ''). + +-- Backfill existing NULLs to the type-appropriate empty value. +UPDATE media_items SET + sort_title = COALESCE(sort_title, ''), + original_title = COALESCE(original_title, ''), + content_rating = COALESCE(content_rating, ''), + overview = COALESCE(overview, ''), + tagline = COALESCE(tagline, ''), + imdb_id = COALESCE(imdb_id, ''), + tmdb_id = COALESCE(tmdb_id, ''), + tvdb_id = COALESCE(tvdb_id, ''), + poster_path = COALESCE(poster_path, ''), + poster_thumbhash = COALESCE(poster_thumbhash, ''), + backdrop_path = COALESCE(backdrop_path, ''), + backdrop_thumbhash = COALESCE(backdrop_thumbhash, ''), + logo_path = COALESCE(logo_path, ''), + metadata_s3_path = COALESCE(metadata_s3_path, ''), + metadata_etag = COALESCE(metadata_etag, '') +WHERE sort_title IS NULL OR original_title IS NULL OR content_rating IS NULL + OR overview IS NULL OR tagline IS NULL OR imdb_id IS NULL OR tmdb_id IS NULL + OR tvdb_id IS NULL OR poster_path IS NULL OR poster_thumbhash IS NULL + OR backdrop_path IS NULL OR backdrop_thumbhash IS NULL OR logo_path IS NULL + OR metadata_s3_path IS NULL OR metadata_etag IS NULL; + +UPDATE media_items SET year = 0 WHERE year IS NULL; +UPDATE media_items SET runtime = 0 WHERE runtime IS NULL; + +-- Establish defaults and NOT NULL atomically (single ACCESS EXCLUSIVE lock). +ALTER TABLE media_items + ALTER COLUMN sort_title SET DEFAULT '', ALTER COLUMN sort_title SET NOT NULL, + ALTER COLUMN original_title SET DEFAULT '', ALTER COLUMN original_title SET NOT NULL, + ALTER COLUMN content_rating SET DEFAULT '', ALTER COLUMN content_rating SET NOT NULL, + ALTER COLUMN overview SET DEFAULT '', ALTER COLUMN overview SET NOT NULL, + ALTER COLUMN tagline SET DEFAULT '', ALTER COLUMN tagline SET NOT NULL, + ALTER COLUMN imdb_id SET DEFAULT '', ALTER COLUMN imdb_id SET NOT NULL, + ALTER COLUMN tmdb_id SET DEFAULT '', ALTER COLUMN tmdb_id SET NOT NULL, + ALTER COLUMN tvdb_id SET DEFAULT '', ALTER COLUMN tvdb_id SET NOT NULL, + ALTER COLUMN poster_path SET DEFAULT '', ALTER COLUMN poster_path SET NOT NULL, + ALTER COLUMN poster_thumbhash SET DEFAULT '', ALTER COLUMN poster_thumbhash SET NOT NULL, + ALTER COLUMN backdrop_path SET DEFAULT '', ALTER COLUMN backdrop_path SET NOT NULL, + ALTER COLUMN backdrop_thumbhash SET DEFAULT '', ALTER COLUMN backdrop_thumbhash SET NOT NULL, + ALTER COLUMN logo_path SET DEFAULT '', ALTER COLUMN logo_path SET NOT NULL, + ALTER COLUMN metadata_s3_path SET DEFAULT '', ALTER COLUMN metadata_s3_path SET NOT NULL, + ALTER COLUMN metadata_etag SET DEFAULT '', ALTER COLUMN metadata_etag SET NOT NULL, + ALTER COLUMN year SET DEFAULT 0, ALTER COLUMN year SET NOT NULL, + ALTER COLUMN runtime SET DEFAULT 0, ALTER COLUMN runtime SET NOT NULL; + +-- +goose Down +ALTER TABLE media_items + ALTER COLUMN sort_title DROP NOT NULL, ALTER COLUMN sort_title DROP DEFAULT, + ALTER COLUMN original_title DROP NOT NULL, ALTER COLUMN original_title DROP DEFAULT, + ALTER COLUMN content_rating DROP NOT NULL, ALTER COLUMN content_rating DROP DEFAULT, + ALTER COLUMN overview DROP NOT NULL, ALTER COLUMN overview DROP DEFAULT, + ALTER COLUMN tagline DROP NOT NULL, ALTER COLUMN tagline DROP DEFAULT, + ALTER COLUMN imdb_id DROP NOT NULL, ALTER COLUMN imdb_id DROP DEFAULT, + ALTER COLUMN tmdb_id DROP NOT NULL, ALTER COLUMN tmdb_id DROP DEFAULT, + ALTER COLUMN tvdb_id DROP NOT NULL, ALTER COLUMN tvdb_id DROP DEFAULT, + ALTER COLUMN poster_path DROP NOT NULL, ALTER COLUMN poster_path DROP DEFAULT, + ALTER COLUMN poster_thumbhash DROP NOT NULL, ALTER COLUMN poster_thumbhash DROP DEFAULT, + ALTER COLUMN backdrop_path DROP NOT NULL, ALTER COLUMN backdrop_path DROP DEFAULT, + ALTER COLUMN backdrop_thumbhash DROP NOT NULL, ALTER COLUMN backdrop_thumbhash DROP DEFAULT, + ALTER COLUMN logo_path DROP NOT NULL, ALTER COLUMN logo_path DROP DEFAULT, + ALTER COLUMN metadata_s3_path DROP NOT NULL, ALTER COLUMN metadata_s3_path DROP DEFAULT, + ALTER COLUMN metadata_etag DROP NOT NULL, ALTER COLUMN metadata_etag DROP DEFAULT, + ALTER COLUMN year DROP NOT NULL, ALTER COLUMN year DROP DEFAULT, + ALTER COLUMN runtime DROP NOT NULL, ALTER COLUMN runtime DROP DEFAULT;