fix(plex): summaries and taglines are stored as plain text

A Plex agent can hand a summary over as HTML (<p> paragraphs, &aacute;
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.
This commit is contained in:
2026-10-09 00:06:02 +02:00
parent 75f4efde98
commit 6dd82ef5e2
8 changed files with 216 additions and 8 deletions
@@ -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 (`<p>…</p>`, `&aacute;`), 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
+2 -1
View File
@@ -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`).""" (genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
from app.plex import people from app.plex import people
from app.plex.plaintext import plain_text
def rich_detail(meta: dict) -> dict: 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], "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], "directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
"studio": meta.get("studio"), "studio": meta.get("studio"),
"tagline": meta.get("tagline"), "tagline": plain_text(meta.get("tagline")),
"cast": people.cast_from_meta(meta), "cast": people.cast_from_meta(meta),
} }
+3 -2
View File
@@ -17,6 +17,7 @@ from app.models import PlexItem, PlexLibrary, PlexShow
from app.plex import sync as plex_sync from app.plex import sync as plex_sync
from app.plex.client import PlexClient from app.plex.client import PlexClient
from app.plex.genres import hu_genres from app.plex.genres import hu_genres
from app.plex.plaintext import plain_text
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
@@ -109,8 +110,8 @@ def _overlay_pass(
for g, meta in hu_by_guid.items(): for g, meta in hu_by_guid.items():
targets = by_guid[g] targets = by_guid[g]
title_hu = (meta.get("title") or "").strip() or None title_hu = (meta.get("title") or "").strip() or None
summary_hu = meta.get("summary") summary_hu = plain_text(meta.get("summary"))
tagline_hu = (meta.get("tagline") or "").strip() or None tagline_hu = plain_text(meta.get("tagline"))
genres_hu = hu_genres(plex_sync._tags(meta, "Genre")) genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
for row in targets: for row in targets:
row.title_hu = title_hu row.title_hu = title_hu
+32
View File
@@ -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: `<p>…</p>` paragraphs and `&aacute;`-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"<br\s*/?>", 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
+4 -3
View File
@@ -26,6 +26,7 @@ from app.plex import detail, overlay, people
from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.genres import hu_genres from app.plex.genres import hu_genres
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
from app.plex.plaintext import plain_text
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
# Codecs/containers a browser can play in a native <video> without any transcoding. # Codecs/containers a browser can play in a native <video> without any transcoding.
@@ -468,7 +469,7 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
# carries none (a server ignoring includeGuids mustn't wipe the overlay's only match key). # carries none (a server ignoring includeGuids mustn't wipe the overlay's only match key).
row.guid = _canonical_guid(meta) or row.guid row.guid = _canonical_guid(meta) or row.guid
row.title = (meta.get("title") or "").strip() or "Untitled" row.title = (meta.get("title") or "").strip() or "Untitled"
row.summary = meta.get("summary") row.summary = plain_text(meta.get("summary"))
row.year = meta.get("year") row.year = meta.get("year")
row.duration_s = _dur_s(meta) row.duration_s = _dur_s(meta)
row.thumb_key = meta.get("thumb") row.thumb_key = meta.get("thumb")
@@ -576,7 +577,7 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
_canonical_guid(meta) or sh.guid _canonical_guid(meta) or sh.guid
) # EN<->HU pairing key; keep prior if listing has none ) # EN<->HU pairing key; keep prior if listing has none
sh.title = (meta.get("title") or "").strip() or "Untitled" sh.title = (meta.get("title") or "").strip() or "Untitled"
sh.summary = meta.get("summary") sh.summary = plain_text(meta.get("summary"))
sh.year = meta.get("year") sh.year = meta.get("year")
sh.thumb_key = meta.get("thumb") sh.thumb_key = meta.get("thumb")
sh.art_key = meta.get("art") sh.art_key = meta.get("art")
@@ -659,7 +660,7 @@ def _upsert_collection(
db.add(col) db.add(col)
col.library_id = lib_id col.library_id = lib_id
col.title = (meta.get("title") or "").strip() or "Untitled" col.title = (meta.get("title") or "").strip() or "Untitled"
col.summary = meta.get("summary") col.summary = plain_text(meta.get("summary"))
col.thumb_key = meta.get("thumb") col.thumb_key = meta.get("thumb")
col.child_count = meta.get("childCount") col.child_count = meta.get("childCount")
col.smart = str(meta.get("smart")) == "1" col.smart = str(meta.get("smart")) == "1"
+123
View File
@@ -0,0 +1,123 @@
"""Plex summaries and taglines are stored as plain text.
A Hungarian TV agent hands summaries over as HTML (`<p>A műsorban … p&aacute;rosnak …</p>`), and
the info page and the hover card showed the tags. `plain_text` is the one cleaner: the sync's every
summary path and the HU overlay store through it, and migration 0101 cleaned the rows written before.
"""
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy.orm import Session
from app.db import engine
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
from app.plex.detail import rich_detail
from app.plex.overlay import sync_overlay
from app.plex.plaintext import plain_text
from app.plex.sync import _apply_item
HU_SUMMARY = "<p>A műsorban minden p&aacute;rosnak stoppal kell eljutnia.</p>"
@pytest.mark.parametrize(
"raw, clean",
[
(HU_SUMMARY, "A műsorban minden párosnak stoppal kell eljutnia."),
("<p>One.</p><p>Two.</p>", "One.\n\nTwo."),
("Line one<br>line two<BR/>three", "Line one\nline two\nthree"),
("<b>Bold</b> &amp; <i>more</i>", "Bold & more"),
# Prose that only looks like markup stays as written.
("a < b and 1 <3 you", "a < b and 1 <3 you"),
("Plain summary.", "Plain summary."),
(" padded ", "padded"),
("<p></p>", None),
("", None),
(None, None),
],
)
def test_plain_text(raw, clean):
assert plain_text(raw) == clean
def test_the_item_sync_stores_a_summary_as_plain_text():
row = PlexItem()
_apply_item(row, lib_id=1, kind="episode", meta={"title": "Ep", "summary": HU_SUMMARY})
assert row.summary == "A műsorban minden párosnak stoppal kell eljutnia."
def test_a_fetched_tagline_is_plain_text():
assert rich_detail({"tagline": "<i>An offer</i> &amp; more"})["tagline"] == "An offer & more"
class _StubPlex:
def __init__(self, items: list[dict]):
self._items = items
def section_items(self, key, item_type, start, size):
return self._items[start : start + size], len(self._items)
def test_the_hungarian_overlay_stores_plain_text(db):
en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu")
db.add_all([en_lib, hu_lib])
db.flush()
hu_lib.overlay_of = en_lib.id
row = PlexItem(rating_key="m1", library_id=en_lib.id, kind="movie", title="Heat", guid="tmdb:1")
db.add(row)
db.commit()
meta = {
"ratingKey": "h1",
"title": "Szemtől szemben",
"summary": HU_SUMMARY,
"tagline": "<i>Egy &eacute;let</i>",
"Guid": [{"id": "tmdb://1"}],
}
sync_overlay(db, _StubPlex([meta]), hu_lib, en_lib.id, {"overlaid": 0})
db.commit()
db.refresh(row)
assert row.summary_hu == "A műsorban minden párosnak stoppal kell eljutnia."
assert row.tagline_hu == "Egy élet"
def test_0101_cleans_the_stored_rows(db):
cfg = Config("alembic.ini")
command.downgrade(cfg, "0100_drop_plex_item_markers")
upgraded = False
try:
with Session(engine) as s:
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
s.add(lib)
s.flush()
ep = PlexItem(
rating_key="e1",
library_id=lib.id,
kind="episode",
title="Ep",
summary=HU_SUMMARY,
tagline_hu="<i>x</i>",
)
plain = PlexItem(
rating_key="m1", library_id=lib.id, kind="movie", title="M", summary="a < b"
)
show = PlexShow(rating_key="s1", library_id=lib.id, title="S", summary_hu="<p>Hu</p>")
col = PlexCollection(rating_key="c1", library_id=lib.id, title="C", summary="A &amp; B")
s.add_all([ep, plain, show, col])
s.commit()
ids = (ep.id, plain.id, show.id, col.id)
command.upgrade(cfg, "head")
upgraded = True
with Session(engine) as s:
ep2, plain2 = s.get(PlexItem, ids[0]), s.get(PlexItem, ids[1])
show2, col2 = s.get(PlexShow, ids[2]), s.get(PlexCollection, ids[3])
assert ep2 and plain2 and show2 and col2
assert ep2.summary == "A műsorban minden párosnak stoppal kell eljutnia."
assert ep2.tagline_hu == "x"
assert plain2.summary == "a < b"
assert show2.summary_hu == "Hu"
assert col2.summary == "A & B"
finally:
if not upgraded:
command.upgrade(cfg, "head")
+1 -1
View File
@@ -468,7 +468,7 @@ export default function PlexInfo({
{detail.summary && ( {detail.summary && (
<p <p
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`} className={`measure mt-3 whitespace-pre-line text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
> >
{detail.summary} {detail.summary}
</p> </p>
+5 -1
View File
@@ -36,7 +36,11 @@ export function MediaPreview({
> >
<p className="text-sm font-semibold leading-snug text-fg">{title}</p> <p className="text-sm font-semibold leading-snug text-fg">{title}</p>
{facts && <div className="mt-0.5 text-xs text-muted">{facts}</div>} {facts && <div className="mt-0.5 text-xs text-muted">{facts}</div>}
{summary && <p className="mt-2 line-clamp-8 text-xs leading-relaxed text-fg">{summary}</p>} {summary && (
<p className="mt-2 line-clamp-8 whitespace-pre-line text-xs leading-relaxed text-fg">
{summary}
</p>
)}
</PopoverSurface> </PopoverSurface>
); );
} }