From 6dd82ef5e2405c22d89110a7c45f32afdb2b6ce3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 9 Oct 2026 00:06:02 +0200 Subject: [PATCH 01/21] fix(plex): summaries and taglines are stored as plain text A Plex agent can hand a summary over as HTML (

paragraphs, á entities); the info page and the hover card showed the tags. One cleaner, app/plex/plaintext.py, now runs on every summary and tagline the sync, the Hungarian overlay and the rich-detail fetch store, and migration 0101 cleans the rows written before through the same function. Paragraph breaks survive as newlines, which the info page and the hover card now keep. --- .../alembic/versions/0101_plex_text_plain.py | 46 +++++++ backend/app/plex/detail.py | 3 +- backend/app/plex/overlay.py | 5 +- backend/app/plex/plaintext.py | 32 +++++ backend/app/plex/sync.py | 7 +- backend/tests/test_plex_plaintext.py | 123 ++++++++++++++++++ frontend/src/components/PlexInfo.tsx | 2 +- frontend/src/components/ui/MediaPreview.tsx | 6 +- 8 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 backend/alembic/versions/0101_plex_text_plain.py create mode 100644 backend/app/plex/plaintext.py create mode 100644 backend/tests/test_plex_plaintext.py diff --git a/backend/alembic/versions/0101_plex_text_plain.py b/backend/alembic/versions/0101_plex_text_plain.py new file mode 100644 index 00000000..4beb4cf7 --- /dev/null +++ b/backend/alembic/versions/0101_plex_text_plain.py @@ -0,0 +1,46 @@ +"""Plex summaries and taglines as plain text + +Revision ID: 0101_plex_text_plain +Revises: 0100_drop_plex_item_markers +Create Date: 2026-10-08 + +Some Plex agents write a summary as HTML (`

…

`, `á`), and the app showed the tags as +text. The sync now stores every summary and tagline through `plain_text` (app/plex/plaintext.py); +this cleans the rows it wrote before, through the same function, so nothing waits for a full resync. +""" + +import sqlalchemy as sa +from alembic import op + +from app.plex.plaintext import plain_text + +revision: str = "0101_plex_text_plain" +down_revision: str | None = "0100_drop_plex_item_markers" +branch_labels: str | None = None +depends_on: str | None = None + +_COLUMNS = { + "plex_items": ("summary", "summary_hu", "tagline", "tagline_hu"), + "plex_shows": ("summary", "summary_hu", "tagline", "tagline_hu"), + "plex_collections": ("summary",), +} + + +def upgrade() -> None: + conn = op.get_bind() + for table, columns in _COLUMNS.items(): + for col in columns: + rows = conn.execute( + sa.text(f"SELECT id, {col} FROM {table} WHERE {col} ~ '[<&]'") + ).fetchall() + updates = [ + {"id": rid, "v": clean} + for rid, value in rows + if (clean := plain_text(value)) != value + ] + if updates: + conn.execute(sa.text(f"UPDATE {table} SET {col} = :v WHERE id = :id"), updates) + + +def downgrade() -> None: + pass # the markup is not wanted back; the next sync would clean it again diff --git a/backend/app/plex/detail.py b/backend/app/plex/detail.py index 46ed2ac6..8830385a 100644 --- a/backend/app/plex/detail.py +++ b/backend/app/plex/detail.py @@ -7,6 +7,7 @@ the two never diverge — the same one-parser rule that keeps `people.py`'s cast (genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`).""" from app.plex import people +from app.plex.plaintext import plain_text def rich_detail(meta: dict) -> dict: @@ -42,7 +43,7 @@ def rich_detail(meta: dict) -> dict: "genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8], "directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4], "studio": meta.get("studio"), - "tagline": meta.get("tagline"), + "tagline": plain_text(meta.get("tagline")), "cast": people.cast_from_meta(meta), } diff --git a/backend/app/plex/overlay.py b/backend/app/plex/overlay.py index 20118283..3306db4f 100644 --- a/backend/app/plex/overlay.py +++ b/backend/app/plex/overlay.py @@ -17,6 +17,7 @@ from app.models import PlexItem, PlexLibrary, PlexShow from app.plex import sync as plex_sync from app.plex.client import PlexClient from app.plex.genres import hu_genres +from app.plex.plaintext import plain_text log = logs.get_logger(__name__) @@ -109,8 +110,8 @@ def _overlay_pass( for g, meta in hu_by_guid.items(): targets = by_guid[g] title_hu = (meta.get("title") or "").strip() or None - summary_hu = meta.get("summary") - tagline_hu = (meta.get("tagline") or "").strip() or None + summary_hu = plain_text(meta.get("summary")) + tagline_hu = plain_text(meta.get("tagline")) genres_hu = hu_genres(plex_sync._tags(meta, "Genre")) for row in targets: row.title_hu = title_hu diff --git a/backend/app/plex/plaintext.py b/backend/app/plex/plaintext.py new file mode 100644 index 00000000..390ee6b7 --- /dev/null +++ b/backend/app/plex/plaintext.py @@ -0,0 +1,32 @@ +"""Plex text fields as plain text. + +Plex hands a summary over as the agent wrote it, and some agents (a Hungarian TV listing, for one) +write HTML: `

…

` paragraphs and `á`-style entities. The app renders summaries as text, +so the tags showed literally on the info page and the hover card, and a card's 300-character cut +could land inside one. Every summary and tagline is stored through `plain_text`, and the migration +that cleaned the existing rows calls it too, so the two cannot disagree. +""" + +import html +import re + +_BREAK = re.compile(r"", re.IGNORECASE) +# A paragraph or block boundary: a blank line between the two. Only a tag name ends the match, so a +# bare "<" in prose ("a < b", "<3") is left alone. +_BLOCK = re.compile(r"]*>", re.IGNORECASE) +_TAG = re.compile(r"]*>") +_BLANKS = re.compile(r"[ \t]*\n[ \t]*\n\s*") + + +def plain_text(value: str | None) -> str | None: + """`value` with its markup gone: line breaks kept as newlines, paragraphs as a blank line, + every other tag dropped and entities decoded. None or an all-blank result stays None.""" + if value is None: + return None + if "<" in value or "&" in value: + value = _BREAK.sub("\n", value) + value = _BLOCK.sub("\n\n", value) + value = _TAG.sub("", value) + value = html.unescape(value) + value = _BLANKS.sub("\n\n", value) + return value.strip() or None diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 9046430a..adbaa69a 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -26,6 +26,7 @@ from app.plex import detail, overlay, people from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.genres import hu_genres from app.plex.lookups import by_rating_key +from app.plex.plaintext import plain_text log = logs.get_logger(__name__) # Codecs/containers a browser can play in a native