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"