diff --git a/CHANGELOG.md b/CHANGELOG.md index fabb98c8..dd014644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.104.0 — 2026-10-10 + +### New + +- YouTube: a card's menu filters the feed to its channel and back, and the player gives the video more room — its side panel shuts on demand. +- Plex: a card's picture plays it and its title opens its page; a show card puts on the episode it would continue or start, and an episode links up to its show. + +### Fixed + +- Plex: summaries written as HTML read as clean text, and a Surprise draw stays put until you reshuffle. A video marked watched in the player stays out of an Unwatched feed. Plus smaller keyboard and focus fixes. + ## 0.103.0 — 2026-10-08 ### New diff --git a/VERSION b/VERSION index 89eba2c5..e49057b3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.103.0 +0.104.0 diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json index 1987e095..a49c0c91 100644 --- a/backend/.basedpyright/baseline.json +++ b/backend/.basedpyright/baseline.json @@ -1134,22 +1134,6 @@ "endColumn": 48, "lineCount": 1 } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 39, - "endColumn": 45, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 38, - "endColumn": 43, - "lineCount": 1 - } } ], "./app/routes/plex/playback.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/discover/pins.py b/backend/app/discover/pins.py index c6fca272..63cb3c88 100644 --- a/backend/app/discover/pins.py +++ b/backend/app/discover/pins.py @@ -24,7 +24,7 @@ from sqlalchemy.orm import Session from app.discover import cards, queries from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video from app.plex import rollup -from app.plex.cards import leaf_card, show_card +from app.plex.cards import SHOW_NEW, leaf_card, show_card from app.plex.lookups import by_rating_key # kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place. @@ -203,8 +203,8 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch, lang: s sh = db.get(PlexShow, pin.plex_show_id) if sh is None: return None - status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new") - card = cards.from_plex(show_card(sh, status, lang=lang)) + state = rollup.state_map(db, user.id, [sh.id]).get(sh.id, SHOW_NEW) + card = cards.from_plex(show_card(sh, state, lang=lang)) ep = queries.next_episode(db, user.id, sh.id, lang=lang) nxt = cards.from_plex(ep) if ep else None diff --git a/backend/app/discover/queries.py b/backend/app/discover/queries.py index 33ad77cf..aa3c965d 100644 --- a/backend/app/discover/queries.py +++ b/backend/app/discover/queries.py @@ -256,16 +256,20 @@ 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).join(prog, prog.c.show_id == PlexShow.id), + 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, prog.c.watched, prog.c.inprog, "in_progress", ).where(PlexShow.library_id.in_(show_libs)) - for sh, resumed_at 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(): - out.append((resumed_at, show_card(sh, "in_progress", lang=lang))) + out.append( + (resumed_at, show_card(sh, rollup.show_state(total, watched, inprog), lang=lang)) + ) return out diff --git a/backend/app/plex/cards.py b/backend/app/plex/cards.py index 4571276f..75a1d9b8 100644 --- a/backend/app/plex/cards.py +++ b/backend/app/plex/cards.py @@ -3,9 +3,24 @@ playlists, and detail views render. Extracted from routes/plex/browse.py so the lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the router. Pure functions — no DB, no request state.""" +from typing import NamedTuple + from app.models import PlexItem, PlexShow, PlexState +class ShowState(NamedTuple): + """A show's watch state rolled up from its episodes, as its card carries it. `resumable`: an unwatched + episode has a resume point, the one the card's Continue resumes and its Restart replays from 0. A + show part watched with its next episode untouched has none: Continue and Restart would both play + that episode from its start, so the card offers Play alone.""" + + status: str + resumable: bool + + +SHOW_NEW = ShowState("new", False) + + # A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item # actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an # `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that @@ -54,7 +69,7 @@ def movie_card(it: PlexItem, st: PlexState | None, *, lang: str) -> dict: } -def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict: +def show_card(sh: PlexShow, state: ShowState = SHOW_NEW, *, lang: str) -> dict: return { "id": sh.rating_key, "type": "show", @@ -66,7 +81,8 @@ def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict: "art": _art_url(sh.rating_key, sh.art_key), "season_count": sh.child_count, # Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge. - "status": status, + "status": state.status, + "resumable": state.resumable, } 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..45ded13e --- /dev/null +++ b/backend/app/plex/plaintext.py @@ -0,0 +1,51 @@ +"""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 +from html.entities import html5 + +_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*") + +# 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©". A named +# one counts only when its whole name is an entity: for "¬ice;" `html.unescape` still falls back +# to the legacy "¬" 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() + if ref[1] != "#": + return html.unescape(ref) if ref[1:] in html5 else ref + if ref[2] in "xX": + return html.unescape(ref) + # `int()` refuses a decimal run past 4300 digits, and any number past seven significant digits is + # beyond U+10FFFF, which the parser turns into U+FFFD anyway. + digits = ref[2:-1].lstrip("0") or "0" + return "�" if len(digits) > 7 else html.unescape(f"&#{digits};") + + +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 = _REF.sub(_decode, value) + value = _BLANKS.sub("\n\n", value) + return value.strip() or None diff --git a/backend/app/plex/rollup.py b/backend/app/plex/rollup.py index 40dd3f69..1f2e65cf 100644 --- a/backend/app/plex/rollup.py +++ b/backend/app/plex/rollup.py @@ -9,6 +9,7 @@ from sqlalchemy import and_, case, func, or_, select from sqlalchemy.orm import Session, aliased from app.models import PlexItem, PlexState +from app.plex.cards import ShowState # Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the # same at every rail that merges two type-homogeneous lists in Python. @@ -32,6 +33,11 @@ def show_status(total: int, watched: int, inprog: int) -> str: return "new" +def show_state(total: int, watched: int, inprog: int) -> ShowState: + """`show_status` plus whether an episode holds a resume point (the card's `ShowState`).""" + return ShowState(show_status(total, watched, inprog), (inprog or 0) > 0) + + def rollup(cards: list[dict]) -> dict: """From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the last in-progress one, else the first unwatched), the first episode (for Play from the start), and @@ -92,16 +98,16 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False): ) -def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]: +def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, ShowState]: if not show_ids: return {} rows = db.execute(agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))).all() - return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows} + return {r.show_id: show_state(r.total, r.watched, r.inprog) for r in rows} 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 {} 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