From 58f836bcd8bb24da7b5bea30ead1a14640638d48 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 25 Jul 2026 18:22:07 +0200 Subject: [PATCH] fix(plex): stop /facets 500 on a metadata-poor library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mirrored Shows library whose items have no genres stored `genres` as JSON `null` — SQLAlchemy's JSONB persists a Python None as a JSON scalar, not SQL NULL. /facets unnested it with jsonb_array_elements_text(), which raises "cannot extract elements from a scalar", so a single bare library 500'd the whole Plex module on open (the grid loads, the filter sidebar's facets call dies). - Guard the /facets genre unnest with jsonb_typeof(genres)='array', mirroring the guard people.py already applies to the same columns. - Persist the JSONB tag columns (genres/directors/cast_names/collection_keys on plex_items and plex_shows) with none_as_null=True, so an empty list round-trips as SQL NULL and self-heals on the next sync — closing the trap for any future unnest. Verified against the real prod row (DumaTV show, genres = JSON null): the unguarded query errors, the guarded one returns cleanly. --- VERSION | 2 +- backend/app/models.py | 19 +++++++++++-------- backend/app/routes/plex.py | 7 ++++++- frontend/src/lib/releaseNotes.ts | 8 ++++++++ 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/VERSION b/VERSION index 328047e..09b1cdc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.53.0 \ No newline at end of file +0.53.1 \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py index c477979..a40b5e9 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1020,16 +1020,19 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): thumb_key: Mapped[str | None] = mapped_column(String(512)) # Plex thumb path (proxied, not stored) art_key: Mapped[str | None] = mapped_column(String(512)) child_count: Mapped[int | None] = mapped_column(Integer) # seasons - collection_keys: Mapped[list | None] = mapped_column(JSONB) # Plex collections this show is in + collection_keys: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # Plex collections this show is in added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) # Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem). rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb) content_rating: Mapped[str | None] = mapped_column(String(16), index=True) studio: Mapped[str | None] = mapped_column(String(255), index=True) # network/studio originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) # first air date - genres: Mapped[list | None] = mapped_column(JSONB) - directors: Mapped[list | None] = mapped_column(JSONB) - cast_names: Mapped[list | None] = mapped_column(JSONB) + # none_as_null: an empty tag list must persist as SQL NULL, not JSON `null`. Without it a Python + # None round-trips as a JSON scalar that jsonb_array_elements_text() can't unnest ("cannot extract + # elements from a scalar") — the trap a metadata-poor library sprang on /facets. Same on plex_items. + genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) + directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) + cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector # Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) @@ -1116,11 +1119,11 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): content_rating: Mapped[str | None] = mapped_column(String(16), index=True) studio: Mapped[str | None] = mapped_column(String(255), index=True) originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) - genres: Mapped[list | None] = mapped_column(JSONB) - directors: Mapped[list | None] = mapped_column(JSONB) - cast_names: Mapped[list | None] = mapped_column(JSONB) + genres: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) + directors: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) + cast_names: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # rating_keys of the Plex collections this movie belongs to (GIN — filter + card-strip siblings). - collection_keys: Mapped[list | None] = mapped_column(JSONB) + collection_keys: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # Cast + director names, space-joined — folded into search_vector (weight B) so the search box # finds titles by a person's name (and ranks them above summary-only mentions). people_text: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 95c42a5..2f82edf 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -675,10 +675,15 @@ def facets( # the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since # every matching title carries them, so they stay togglable.) genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None} + # Only rows whose `genres` is a JSON array can be unnested. A genre-less item stores JSON + # `null` (SQLAlchemy's JSONB persists a Python None as JSON null, not SQL NULL), and + # jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") — + # so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the + # whole facets endpoint. Mirror the exact guard people.py already applies to the same columns. expanded = wsq( db.query(func.jsonb_array_elements_text(model.genres).label("g")), model, ids, genre_pp, kind_movie, True, - ).subquery() + ).filter(func.jsonb_typeof(model.genres) == "array").subquery() for g, c in db.query(expanded.c.g, func.count()).group_by(expanded.c.g).all(): genre_counts[g] = genre_counts.get(g, 0) + c # Content ratings — exclude its own. diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index fce6092..5bfab89 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,14 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.53.1", + date: "2026-07-25", + summary: "The Plex page no longer errors when a mirrored library has items without genres.", + fixes: [ + "Opening the Plex module could fail with a server error when a mirrored library contained items with no genre metadata (for example a freshly added, sparsely-tagged library). The filter sidebar now handles genre-less items correctly, so the page loads normally.", + ], + }, { version: "0.53.0", date: "2026-07-23",