From 76738f8ea9e2cb266d6b900ac44d577f76257a91 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 9 Oct 2026 22:01:27 +0200 Subject: [PATCH] fix(plex): plain text decodes only well-formed character references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit html.unescape also decodes the legacy forms without a semicolon, so prose like "AT¬ice" came out as "AT¬ice". The show and collection sync paths are now tested too. --- backend/app/plex/plaintext.py | 6 ++++- backend/tests/test_plex_plaintext.py | 33 +++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/backend/app/plex/plaintext.py b/backend/app/plex/plaintext.py index 390ee6b7..6f3c832b 100644 --- a/backend/app/plex/plaintext.py +++ b/backend/app/plex/plaintext.py @@ -17,6 +17,10 @@ _BLOCK = re.compile(r"]*>", re.IGNOR _TAG = re.compile(r"]*>") _BLANKS = re.compile(r"[ \t]*\n[ \t]*\n\s*") +# A well-formed character reference only. `html.unescape` alone also decodes the legacy forms that +# have no semicolon, so prose like "AT¬ice" or "R©" came out as "AT¬ice" and "R©". +_REF = re.compile(r"&(?:#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);") + def plain_text(value: str | None) -> str | None: """`value` with its markup gone: line breaks kept as newlines, paragraphs as a blank line, @@ -27,6 +31,6 @@ def plain_text(value: str | None) -> str | None: value = _BREAK.sub("\n", value) value = _BLOCK.sub("\n\n", value) value = _TAG.sub("", value) - value = html.unescape(value) + value = _REF.sub(lambda m: html.unescape(m.group()), value) value = _BLANKS.sub("\n\n", value) return value.strip() or None diff --git a/backend/tests/test_plex_plaintext.py b/backend/tests/test_plex_plaintext.py index 9ec733e5..572cc320 100644 --- a/backend/tests/test_plex_plaintext.py +++ b/backend/tests/test_plex_plaintext.py @@ -8,6 +8,7 @@ summary path and the HU overlay store through it, and migration 0101 cleaned the import pytest from alembic import command from alembic.config import Config +from sqlalchemy import select from sqlalchemy.orm import Session from app.db import engine @@ -15,7 +16,8 @@ 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 +from app.plex import sync +from app.plex.sync import _apply_item, _upsert_collection HU_SUMMARY = "

A műsorban minden párosnak stoppal kell eljutnia.

" @@ -29,6 +31,9 @@ HU_SUMMARY = "

A műsorban minden párosnak stoppal kell eljutnia.

" ("Bold & more", "Bold & more"), # Prose that only looks like markup stays as written. ("a < b and 1 <3 you", "a < b and 1 <3 you"), + # Only a reference with its semicolon is one: an ampersand in prose stays as written. + ("AT¬ice, R© and H&M", "AT¬ice, R© and H&M"), + ("été ©", "été ©"), ("Plain summary.", "Plain summary."), (" padded ", "padded"), ("

", None), @@ -46,6 +51,32 @@ def test_the_item_sync_stores_a_summary_as_plain_text(): assert row.summary == "A műsorban minden párosnak stoppal kell eljutnia." +def test_the_show_sync_stores_a_summary_as_plain_text(db, monkeypatch): + lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add(lib) + db.commit() + meta = {"ratingKey": "s1", "title": "Show", "summary": HU_SUMMARY} + + def listing(_plex, _key, typ, progress=None): + if progress is not None: + progress["complete"] = True + return [meta] if typ == sync._TYPE_SHOW else [] + + monkeypatch.setattr(sync, "_paginate", listing) + sync._sync_shows(db, object(), lib, {"shows": 0, "seasons": 0, "episodes": 0}) + db.commit() + show = db.execute(select(PlexShow).where(PlexShow.rating_key == "s1")).scalar_one() + assert show.summary == "A műsorban minden párosnak stoppal kell eljutnia." + + +def test_a_collection_stores_its_summary_as_plain_text(db): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + col = _upsert_collection(db, None, "c1", lib.id, {"title": "C", "summary": "

A & B

"}) + assert col.summary == "A & B" + + def test_a_fetched_tagline_is_plain_text(): assert rich_detail({"tagline": "An offer & more"})["tagline"] == "An offer & more"