fix(plex): the rails' Restart comes from the one rollup; plain text decodes whole entity names only

Both Continue-watching rails built a show's card state inline; they now take it from rollup.show_state, so a show part watched with nothing to resume offers Play alone there too. A named reference counts only when its whole name is an HTML5 entity: html.unescape still decoded the legacy "&not" inside "&notice;". Tests cover the grid's in-progress-but-not-resumable case through its SQL and both rails.
This commit is contained in:
2026-10-09 23:46:39 +02:00
parent 6df257d7df
commit fc53bc03bc
7 changed files with 51 additions and 14 deletions
+6 -5
View File
@@ -40,7 +40,7 @@ from app.models import (
VideoState,
)
from app.plex import rollup
from app.plex.cards import ShowState, display_title, leaf_card, movie_card, show_card
from app.plex.cards import display_title, leaf_card, movie_card, show_card
from app.plex.query import apply_show_state_filter, content_libs
# How many rows a single rail may carry — Home's rails and the Plex module's Discover rails alike
@@ -256,7 +256,7 @@ def plex_in_progress(
if show_libs:
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
sq = apply_show_state_filter(
select(PlexShow, prog.c.resumed_at, prog.c.inprog).join(
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
prog, prog.c.show_id == PlexShow.id
),
prog.c.total,
@@ -264,11 +264,12 @@ def plex_in_progress(
prog.c.inprog,
"in_progress",
).where(PlexShow.library_id.in_(show_libs))
for sh, resumed_at, inprog in db.execute(
for sh, resumed_at, total, watched, inprog in db.execute(
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
).all():
state = ShowState("in_progress", (inprog or 0) > 0)
out.append((resumed_at, show_card(sh, state, lang=lang)))
out.append(
(resumed_at, show_card(sh, rollup.show_state(total, watched, inprog), lang=lang))
)
return out
+10 -2
View File
@@ -9,6 +9,7 @@ that cleaned the existing rows calls it too, so the two cannot disagree.
import html
import re
from html.entities import html5
_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
@@ -18,10 +19,17 @@ _TAG = re.compile(r"</?[a-zA-Z][^>]*>")
_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&notice" or "R&copy" came out as "AT¬ice" and "R©".
# have no semicolon, so prose like "AT&notice" or "R&copy" came out as "AT¬ice" and "R©". A named
# one counts only when its whole name is an entity: for "&notice;" `html.unescape` still falls back
# to the legacy "&not" inside it.
_REF = re.compile(r"&(?:#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);")
def _decode(m: re.Match[str]) -> str:
ref = m.group()
return html.unescape(ref) if ref[1] == "#" or ref[1:] in html5 else ref
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."""
@@ -31,6 +39,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 = _REF.sub(lambda m: html.unescape(m.group()), value)
value = _REF.sub(_decode, value)
value = _BLANKS.sub("\n\n", value)
return value.strip() or None
+1 -1
View File
@@ -107,7 +107,7 @@ def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, ShowS
def item_state_map(db: Session, user_id: int, item_ids: list[int]) -> dict[int, PlexState]:
"""A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the
leaf analogue of `state_map` (which rolls a show's episodes up to a status string). One batched
leaf analogue of `state_map` (which rolls a show's episodes up to a `ShowState`). One batched
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
if not item_ids:
return {}
+3 -4
View File
@@ -25,7 +25,6 @@ from app.models import PlexItem, PlexShow, PlexState, User
from app.plex import rollup
from app.plex.cards import (
SHOW_NEW,
ShowState,
display_title,
leaf_card,
movie_card,
@@ -239,7 +238,7 @@ def _continue_watching(
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
sq = apply_show_state_filter(
select(PlexShow, prog.c.resumed_at, prog.c.inprog).join(
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
prog, prog.c.show_id == PlexShow.id
),
prog.c.total,
@@ -252,8 +251,8 @@ def _continue_watching(
.order_by(prog.c.resumed_at.desc().nullslast())
.limit(RAIL_LIMIT)
).all()
for sh, resumed_at, inprog in srows:
state = ShowState("in_progress", (inprog or 0) > 0)
for sh, resumed_at, total, watched, inprog in srows:
state = rollup.show_state(total, watched, inprog)
rows.append((resumed_at, show_card(sh, state, lang=lang)))
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)