Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Changed

- Give a Live TV title without art its channel's logo large over the logo's blurred colours, in place of the title on dark grey.
- Give a Live TV block named after its channel the same card, in place of art from the channel's show with the most airtime.

### Fixed

- Upload Live TV posters again at the next sweep when Plex deletes them while cleaning bundles.
Expand Down
3 changes: 1 addition & 2 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,9 @@ Built-in marks: Netflix, Prime Video, Apple TV, Disney+, HBO Max, Hulu, Paramoun

- A title that matches exactly one TMDB title gets a poster in the library style, without a label. When several TMDB titles have that exact name, the one with at least 3 times the votes of the next is used; otherwise none. A note at the end of the guide title, such as `(Tekrar)`, is left out. The logo is in the language of the guide's title. When TMDB has no logo in that language, the guide's title is written out. TMDB must have art in poster shape; a title with only wide art gets one of the posters below.
- A title with its own picture in the guide gets that picture with the title. A picture that several titles share, such as a channel promo, is not a title's own. A picture more than 4 times wider than high, or higher than wide, is left out.
- Any other title gets the title on dark grey.
- Any other title, and a block named after its channel, gets its channel's logo, large, over the logo's own colours blurred, with the title below. A dark logo sits on a light wash of its colours. A channel without a logo, or with an empty one, gives the title on dark grey.
- These posters carry the channel's logo in its own colours, where a TV show poster carries its streaming mark. Plex keeps one guide entry for a title on several channels, so its poster has no channel logo. When the title is left on one channel in the next 4 days, the poster is made again with that channel's logo.
- Plex's own guide, used with antenna tuners, comes with artwork. There only titles found on TMDB get a Posteryard poster; the rest keep Plex's image.
- A block named after its channel gets a [service collection](#collections) tile: art of the channel's show with the most airtime that has TMDB art, over a dark grey band with the channel's logo. A channel without such a show gets the band with its logo alone.

Plex rebuilds the guide at every guide refresh, about once a day, and shows the guide's own images again. Posteryard checks at the next sweep and selects its posters again without uploading them twice. Plex deletes these uploads when it cleans bundles, once a week by default and when a tool such as ImageMaid asks for it. Posteryard checks at every sweep and uploads them again. Plex can return the wrong entry for a title that starts another title, such as "News" and "News at Ten". Posteryard then puts the poster on the entry by its id. The channel logos left of the guide come from the guide; Plex takes no upload there. `posteryard preview --live-tv` saves all guide posters to a folder without changing the server.

Expand Down
46 changes: 7 additions & 39 deletions src/posteryard/livetv.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ class GuideShow:
logo: str
picture: str
guid: str = ""
airtime: int = 0
targets: tuple[str, ...] = ()
# The guide's picture before shared pictures are dropped. Stored, so a later run can count its users.
guide_picture: str = ""
Expand Down Expand Up @@ -130,21 +129,17 @@ def guide_shows(
if picture:
# The same show on several channels has one key per channel on Jellyfin. Its title is the same.
users.setdefault(picture, set()).add(norm(title) or key)
length = int(media.get("endsAt") or 0) - int(media.get("beginsAt") or 0)
channels.setdefault(key, set()).add(str(media.get("channelCallSign") or media.get("channelTitle") or ""))
if key not in shows:
channel = str(media.get("channelCallSign") or media.get("channelTitle") or "")
guid = str((airing.get("guid") if movie else airing.get("grandparentGuid")) or "")
kind: Kind = "movie" if movie else "tv"
shows[key] = GuideShow(
provider, key, kind, title, channel, logo, picture, guid, max(length, 0), (target,), picture
)
shows[key] = GuideShow(provider, key, kind, title, channel, logo, picture, guid, (target,), picture)
else:
known = shows[key]
targets = known.targets if target in known.targets else (*known.targets, target)
airtime = known.airtime + max(length, 0)
picture = known.picture or picture
shows[key] = replace(known, picture=picture, airtime=airtime, targets=targets, guide_picture=picture)
shows[key] = replace(known, picture=picture, targets=targets, guide_picture=picture)
return [
replace(
show,
Expand Down Expand Up @@ -175,8 +170,6 @@ def __init__( # noqa: PLR0913
self.server, self.store, self.ctx = server, store, ctx
self.dry_run, self.preview_dir = dry_run, preview_dir
self._fetch = fetch
self._shows: list[GuideShow] = []
self._featured: dict[tuple[str, str], str | None] = {}
self._unreadable: set[str] = set()
self._confirmed: list[str] = []

Expand Down Expand Up @@ -227,7 +220,7 @@ def run(self, now: float | None = None, tick: Callable[[], bool] = lambda: True)
for guide in guides:
airings = self.server.guide_airings(guide.provider, int(now), int(now + WINDOW_SECONDS))
shows = guide_shows(guide.provider, airings, self._remembered_picture)
self._shows, self._featured, self._unreadable = shows, {}, set()
self._unreadable = set()
for show in shows:
if not tick():
return tally
Expand Down Expand Up @@ -346,25 +339,6 @@ def match(self, show: GuideShow) -> int | None:
self.store.put_choice(cache_key, {"id": found, "at": time.time()})
return found or None

def featured_art(self, show: GuideShow) -> str | None:
"""TMDB art of the show with the most airtime on this channel, for the channel's own blocks."""
channel = (show.provider, norm(show.channel))
if channel not in self._featured:
self._featured[channel] = None
siblings = [
s for s in self._shows if (s.provider, norm(s.channel)) == channel and norm(s.title) != norm(s.channel)
]
for sibling in sorted(siblings, key=lambda s: (-s.airtime, s.title)):
tid = self.match(sibling)
picked = None
if tid is not None:
title = self.ctx.sources.title(sibling.kind, tid, sibling.title, remember=False)
picked = self.ctx.sources.art(title)
if picked is not None:
self._featured[channel] = picked.path
break
return self._featured[channel]

def _image(self, fetch: Callable[[str], Image.Image], url: str, show: GuideShow, what: str) -> Image.Image | None:
"""An image the poster cannot use, such as an SVG logo or a broken link, is left out instead of failing."""
if not url or url in self._unreadable:
Expand All @@ -382,7 +356,7 @@ def _image(self, fetch: Callable[[str], Image.Image], url: str, show: GuideShow,
return None

def plan(self, show: GuideShow, fetch: Callable[[str], Image.Image]) -> pipeline.Plan:
"""A block named after its channel gets the channel card. Then TMDB art, the guide's picture, a title card.
"""TMDB art, then the guide's picture, then a title card. A block named after its channel gets the title card.

`fetch` is for channel logos, which repeat. Guide pictures are fetched once per show without the cache.
"""
Expand All @@ -392,13 +366,6 @@ def plan(self, show: GuideShow, fetch: Callable[[str], Image.Image]) -> pipeline
base = {"live": LIVE_VERSION, "title": show.title}
own_art = show.provider.startswith(OWN_ART)
placeholder = norm(show.title) == norm(show.channel)
if placeholder and mark is not None and not own_art:
art = self.featured_art(show)
return pipeline.Plan(
key, "poster", show.title, {**base, "design": "live-channel", "logo": logo, "art": art},
lambda: livetv.channel_card(mark, self.ctx.sources.load(art) if art else None),
[f"channel logo {http.redact(logo)}"] + ([f"art from {pipeline.art_source(art)}"] if art else []),
) # fmt: skip
tid = None if placeholder else self.match(show)
plan: pipeline.Plan | None = None
if tid is not None:
Expand All @@ -409,7 +376,8 @@ def plan(self, show: GuideShow, fetch: Callable[[str], Image.Image]) -> pipeline
log.debug("live TV show has no TMDB art", extra={"title": show.title, "reason": str(exc)})
if plan is None and own_art:
raise LeftAloneError(show.title)
picture = self._image(self._fetch, show.picture, show, "guide picture") if plan is None else None
use_picture = plan is None and not placeholder
picture = self._image(self._fetch, show.picture, show, "guide picture") if use_picture else None
if plan is None and picture is not None:
plan = pipeline.Plan(
key, "poster", show.title, {**base, "design": "live-picture", "picture": show.picture},
Expand All @@ -418,7 +386,7 @@ def plan(self, show: GuideShow, fetch: Callable[[str], Image.Image]) -> pipeline
) # fmt: skip
if plan is None:
return pipeline.Plan(
key, "poster", show.title, {**base, "design": "live-title", "logo": logo},
key, "poster", show.title, {**base, "design": "live-frosted" if logo else "live-title", "logo": logo},
lambda: livetv.title_card(mark, designs.text_logo(shown(show.title), rows=3)),
[f"channel logo {http.redact(logo)}" if logo else "no channel logo"],
) # fmt: skip
Expand Down
60 changes: 38 additions & 22 deletions src/posteryard/render/livetv.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
"""Live TV cards in the style of the service collection tile, and the channel logo in the streaming mark's corner."""
"""Live TV cards, and the channel logo in the streaming mark's corner."""

import math

import numpy as np
from PIL import Image, ImageDraw, ImageFilter

from posteryard.render import designs
from posteryard.render.layers import cover, trim, vertical_gradient
from posteryard.render.layers import cover, trim

W, H = designs.POSTER
CORNER = 0.12
CHANNEL_BOX, CHANNEL_CENTRE = (0.72, 0.2), 0.42
BAND_LOGO_BOX = (0.6, 0.13)
PICTURE_TOP = 0.18
PICTURE_BLUR, PICTURE_DIM = 40, 0.7
FROSTED_BOX, FROSTED_CENTRE = (0.62, 0.16), 0.36
FROSTED_GRID, FROSTED_BLUR, FROSTED_DIM, FROSTED_SHADE = (6, 9), 80, 0.5, 0.45
FROSTED_LIGHT_DIM, FROSTED_LIGHT_TINT, DARK_LOGO = 0.85, 0.35, 0.25
BAR_SPREAD = 6.0
# Covering the poster scales a thin picture by its aspect. A 1920x10 picture needs 1.7 GB.
PICTURE_MAX_ASPECT = 4.0
Expand All @@ -22,7 +23,10 @@
def logo_mark(image: Image.Image) -> Image.Image:
"""A transparent logo is cut to its shape. An opaque logo, such as a photo, gets rounded corners."""
image = image.convert("RGBA")
if int(np.asarray(image.getchannel("A")).min()) < 255:
alpha = np.asarray(image.getchannel("A"))
if int(alpha.max()) <= 8:
raise ValueError("the logo is fully transparent")
if int(alpha.min()) < 255:
return trim(image)
mask = Image.new("L", image.size, 0)
radius = round(min(image.size) * CORNER)
Expand Down Expand Up @@ -56,26 +60,38 @@ def with_channel(poster: Image.Image, logo: Image.Image) -> Image.Image:
return canvas.convert("RGB")


def channel_card(logo: Image.Image, art: Image.Image | None = None) -> Image.Image:
"""With art from one of the channel's shows: the service collection tile. Without: the band alone."""
if art is None:
canvas = band(H).convert("RGBA")
mark = _fit(logo, CHANNEL_BOX)
canvas.alpha_composite(mark, ((W - mark.width) // 2, round(CHANNEL_CENTRE * H - mark.height / 2)))
return canvas.convert("RGB")
split = round(designs.CHANNEL_SEAM * H)
canvas = Image.new("RGBA", (W, H))
canvas.paste(cover(art, W, split, (0.5, 0.25)), (0, 0))
canvas.alpha_composite(vertical_gradient((W, split), designs.CHANNEL_TOP_SHADE), (0, 0))
canvas.paste(band(H - split), (0, split))
mark = _fit(logo, BAND_LOGO_BOX)
canvas.alpha_composite(mark, ((W - mark.width) // 2, round((split + H) / 2 - mark.height / 2)))
return canvas.convert("RGB")
def dark_logo(logo: Image.Image) -> bool:
"""A logo whose visible pixels are dark on average."""
rgba = np.asarray(logo.convert("RGBA"), dtype=np.float32) / 255
alpha = rgba[..., 3]
luminance = rgba[..., :3] @ np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
return float((luminance * alpha).sum() / alpha.sum()) < DARK_LOGO


def title_card(logo: Image.Image | None, title: Image.Image) -> Image.Image:
card = designs.tile_poster(band(H), title, lines_below=[], fade=0)
return with_channel(card, logo) if logo is not None else card
"""The channel's logo large over its own colours, frosted. Without a logo: the title on dark grey.

A dark logo is frosted over white, so it stays visible.
"""
if logo is None:
return designs.tile_poster(band(H), title, lines_below=[], fade=0)
light = dark_logo(logo)
flat = Image.new("RGBA", logo.size, (255, 255, 255, 255) if light else (0, 0, 0, 255))
tint = logo.copy()
if light:
tint.putalpha(logo.getchannel("A").point(lambda v: round(v * FROSTED_LIGHT_TINT)))
flat.alpha_composite(tint)
tiny = cover(flat, *FROSTED_GRID).resize((W, H), Image.Resampling.BICUBIC)
dim = FROSTED_LIGHT_DIM if light else FROSTED_DIM
frosted = np.asarray(tiny.filter(ImageFilter.GaussianBlur(FROSTED_BLUR)), dtype=np.float32) * dim
if not light:
ys, xs = np.ogrid[:H, :W]
distance = np.hypot((xs - W / 2) / (0.55 * W), (ys - FROSTED_CENTRE * H) / (0.18 * H))
frosted *= (1 - FROSTED_SHADE * np.clip(1 - distance, 0, 1) ** 2)[..., None]
canvas = Image.fromarray(frosted.astype(np.uint8), "RGB").convert("RGBA")
mark = _fit(logo, FROSTED_BOX)
canvas.alpha_composite(mark, ((W - mark.width) // 2, round(FROSTED_CENTRE * H - mark.height / 2)))
return designs.tile_poster(canvas.convert("RGB"), title, lines_below=[])


def trim_bars(image: Image.Image) -> Image.Image:
Expand Down
65 changes: 26 additions & 39 deletions tests/test_livetv.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@
LOGO = "https://logos.example/news.png"


def airing( # noqa: PLR0913
title: str, key: str, *, channel: str = "News 24", thumb: str = "", movie: bool = False, length: int = 0
) -> Item:
media = {"channelCallSign": channel, "channelThumb": LOGO, "beginsAt": 1000, "endsAt": 1000 + length}
def airing(title: str, key: str, *, channel: str = "News 24", thumb: str = "", movie: bool = False) -> Item:
media = {"channelCallSign": channel, "channelThumb": LOGO}
if movie:
return {"type": "movie", "ratingKey": key, "title": title, "thumb": thumb, "guid": f"g/{key}", "Media": [media]}
return { # type: ignore[typeddict-unknown-key]
Expand Down Expand Up @@ -210,36 +208,16 @@ def test_match_refuses_a_tie_but_takes_a_clear_favourite(tmp_path: Path) -> None
assert live.match(guide_shows(GUIDE, [airing("Tuzak", "b")])[0]) == 3


def test_a_block_named_after_its_channel_gets_the_channel_card(tmp_path: Path) -> None:
def test_a_block_named_after_its_channel_gets_the_title_card(tmp_path: Path) -> None:
live, _, tmdb = build(tmp_path, [])
show = guide_shows(GUIDE, [airing("News 24", "a", thumb="https://img.example/own.jpg")])[0]
plan = live.plan(show, fetch)
assert plan.inputs["design"] == "live-channel"
assert plan.inputs["art"] is None
assert plan.inputs["design"] == "live-frosted"
assert "picture" not in plan.inputs
assert tmdb.searches == []
assert plan.draw().size == (1000, 1500)


def test_a_channel_block_shows_art_of_the_channel_show_with_most_airtime(tmp_path: Path) -> None:
live, _, tmdb = build(tmp_path, [])
tmdb.results["Long"] = [{"id": 5, "name": "Long"}]
tmdb.results["Short"] = [{"id": 6, "name": "Short"}]
live._shows = guide_shows(
GUIDE,
[
airing("Short", "s", length=600),
airing("Long", "l", length=600),
airing("Long", "l", length=600),
airing("Other channel", "o", channel="Elsewhere", length=9000),
airing("News 24", "n", length=600),
],
)
plan = live.plan(live._shows[-1], fetch)
assert plan.inputs["art"] == "/poster.jpg"
assert tmdb.searches == ["Long"]
assert plan.draw().size == (1000, 1500)


def test_plan_prefers_tmdb_then_the_guide_picture_then_a_title_card(tmp_path: Path) -> None:
live, _, tmdb = build(tmp_path, [])
tmdb.results["Example Movie"] = [{"id": 5, "name": "Example Movie"}]
Expand All @@ -251,7 +229,7 @@ def test_plan_prefers_tmdb_then_the_guide_picture_then_a_title_card(tmp_path: Pa
picture = live.plan(guide_shows(GUIDE, [airing("Talk", "b", thumb="https://img.example/talk.jpg")])[0], fetch)
assert picture.inputs["design"] == "live-picture"
titled = live.plan(guide_shows(GUIDE, [airing("Bulletin", "c")])[0], fetch)
assert titled.inputs["design"] == "live-title"
assert titled.inputs["design"] == "live-frosted"
assert picture.draw().size == titled.draw().size == (1000, 1500)


Expand Down Expand Up @@ -495,16 +473,6 @@ def test_logo_mark_cuts_transparent_logos_and_rounds_opaque_ones() -> None:
assert photo.getchannel("A").getpixel((100, 100)) == 255


def test_channel_cards_use_the_collection_band_with_or_without_art() -> None:
plain = cards.channel_card(logo())
assert plain.size == (1000, 1500)
assert plain.getpixel((10, 10)) == cards.band(1500).getpixel((10, 10))
with_art = cards.channel_card(logo(), Image.new("RGB", (600, 900), (200, 180, 40)))
assert with_art.getpixel((500, 600)) != plain.getpixel((500, 600))
split = round(designs.CHANNEL_SEAM * 1500)
assert with_art.getpixel((10, 1400)) == cards.band(1500 - split).getpixel((10, 1400 - split))


def test_the_channel_logo_sits_where_a_streaming_mark_does() -> None:
poster = cards.with_channel(Image.new("RGB", (1000, 1500), (0, 0, 0)), logo())
assert poster.getpixel((60, 60)) == (220, 40, 40)
Expand All @@ -517,7 +485,7 @@ def test_a_picture_too_thin_for_a_poster_gets_the_title_card(tmp_path: Path, cap
show = guide_shows(GUIDE, [airing("Talk", "b", thumb="https://img.example/strip.jpg")])[0]
live._fetch = lambda url: strip if url.endswith("strip.jpg") else fetch(url)
plan = live.plan(show, fetch)
assert plan.inputs["design"] == "live-title"
assert plan.inputs["design"] == "live-frosted"
assert any("too thin for a poster" in str(getattr(r, "reason", "")) for r in caplog.records)
assert cards.guide_picture(Image.effect_noise((1920, 1080), 60)).size == (1920, 1080)

Expand Down Expand Up @@ -628,3 +596,22 @@ def test_a_tmdb_title_with_only_wide_art_is_treated_as_not_found(
own = replace(show, provider="tv.plex.providers.epg.cloud:20")
with pytest.raises(LeftAloneError):
live.plan(own, fetch)


def test_the_title_card_is_frosted_in_the_logo_colours_and_grey_without_a_logo() -> None:
title = designs.text_logo("Bulletin", rows=3)
away_from_the_corner = (cards.W - 60, round(cards.H * 0.6))
red, green, blue = cards.title_card(logo(), title).getpixel(away_from_the_corner) # type: ignore[misc]
assert red > 2 * max(green, blue)
grey = cards.title_card(None, title).getpixel(away_from_the_corner)
assert max(grey) - min(grey) < 10 # type: ignore[arg-type]


def test_a_dark_logo_is_frosted_over_white_and_a_see_through_logo_is_refused() -> None:
title = designs.text_logo("Bulletin", rows=3)
navy = cards.logo_mark(logo((20, 30, 90)))
assert cards.dark_logo(navy)
assert not cards.dark_logo(cards.logo_mark(logo()))
assert min(cards.title_card(navy, title).getpixel((60, 60))) > 120 # type: ignore[arg-type]
with pytest.raises(ValueError, match="transparent"):
cards.logo_mark(Image.new("RGBA", (300, 120), (0, 0, 0, 0)))