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"?(?:p|div|li|ul|ol|h[1-6]|blockquote)\b[^>]*>", re.IGNORECASE)
+_TAG = re.compile(r"?[a-zA-Z][^>]*>")
+_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