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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Center all three lines:
| `server_name` | string | No | - | Which server to use when your account has several and `server_url` is empty |
| `plex_user` | string | No | - | Only show this Plex user's streams |
| `show_accents` | boolean | No | `true` | Frame the year or season/episode line with yellow tiles |
| `hold_seconds` | integer | No | `60` | After playback stops, keep showing it this long before "Nothing playing" (0-600, 0 turns it off) |
| `refresh_seconds` | integer | No | `30` | How often to ask Plex what's playing (10-600) |
| `enabled` | boolean | No | `false` | Enable the plugin |

Expand All @@ -119,6 +120,7 @@ With no `server_url`, the plugin asks plex.tv for the servers on your account (`
- Optional Plex yellow accent tiles
- Filter to a single Plex user
- Every active stream available as `sessions`, plus a stream count
- No blank board between episodes: the last stream stays up through Plex's Up Next countdown
- Playback state color-coded: green playing, yellow paused, orange buffering
- Sign in with Plex, or paste a token
- Finds your server through your Plex account, or talks to the address you give it directly
Expand Down
28 changes: 27 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import math
import os
import textwrap
import time
import uuid
from typing import Any, Dict, List, Optional, Tuple

Expand All @@ -25,12 +26,17 @@
ACCENT = "{65}{65}" # two yellow tiles, Plex's brand colour
REQUEST_TIMEOUT = 10
PROBE_TIMEOUT = 3
# Plex drops a session the moment an episode ends and only starts the next one after its
# Up Next countdown; holding the last stream this long keeps the board from blanking out.
DEFAULT_HOLD_SECONDS = 60
MAX_HOLD_SECONDS = 600
PLEX_RESOURCES_URL = "https://plex.tv/api/v2/resources"

NEEDS_TOKEN = "Sign in with Plex, or paste a Plex token, in the plugin settings"
SIGN_IN_REJECTED = "Plex stopped accepting the sign-in. Sign in with Plex again."
TOKEN_REJECTED = "Plex rejected the token (401 Unauthorized)"
SET_SERVER_URL = "set the Plex Server URL in the plugin settings"
BAD_HOLD = f"Hold must be between 0 and {MAX_HOLD_SECONDS} seconds"

STATE_LABELS = {
"playing": "Playing",
Expand Down Expand Up @@ -242,6 +248,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
self._client_identifier = str(uuid.uuid4())
# ((token, server_name), server_url, server_token) from the last plex.tv lookup
self._discovered: Optional[Tuple[Tuple[str, str], str, str]] = None
# The streams last seen, and when Plex first reported none since then
self._last_sessions: List[Dict[str, Any]] = []
self._idle_since: Optional[float] = None

@property
def plugin_id(self) -> str:
Expand All @@ -263,9 +272,24 @@ def validate_config(self, config: Dict[str, Any]) -> List[str]:
if server_url and not server_url.startswith(("http://", "https://")):
errors.append("Plex server URL must start with http:// or https://")

hold = config.get("hold_seconds", DEFAULT_HOLD_SECONDS)
if isinstance(hold, bool) or not isinstance(hold, (int, float)) or not 0 <= hold <= MAX_HOLD_SECONDS:
errors.append(BAD_HOLD)

errors.extend(self._validate_refresh_seconds(config))
return errors

def _hold_through_gaps(self, sessions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""*sessions*, or the last streams seen while Plex has reported none for under the hold time."""
if sessions:
self._last_sessions, self._idle_since = sessions, None
return sessions
now = time.monotonic()
if self._idle_since is None:
self._idle_since = now
hold = self.config.get("hold_seconds", DEFAULT_HOLD_SECONDS)
return self._last_sessions if now - self._idle_since < hold else sessions

def _signed_in_token(self) -> str:
"""The token from Sign in with Plex, or "" (not signed in, or a core without sign-in)."""
getter = getattr(self, "get_oauth_token", None)
Expand Down Expand Up @@ -387,11 +411,13 @@ def fetch_data(self) -> PluginResult:
logger.warning("Plex request failed: %s", e)
return PluginResult(available=False, error=str(e))

sessions = [parse_session(item) for item in items]
# A session with no title is one Plex has not loaded yet: nothing to show
sessions = [parse_session(item) for item in items if item.get("title")]

plex_user = (self.config.get("plex_user") or "").strip().lower()
if plex_user:
sessions = [s for s in sessions if s["user"].lower() == plex_user]
sessions = self._hold_through_gaps(sessions)

primary = pick_primary(sessions) if sessions else None
others = [s for s in sessions if s is not primary]
Expand Down
10 changes: 10 additions & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ This is how the plugin worked before 1.1.0, and it still does. A pasted token is
| Plex Server Name | No | - | Which server to use when the URL is empty and your account has several |
| Plex User | No | - | Only show streams from this Plex user (case-insensitive) |
| Plex yellow accents | No | On | Frame the year or season/episode line with two yellow tiles on each side |
| Hold Between Episodes | No | 60 seconds | After playback stops, keep showing it this long before "Nothing playing" (0-600 seconds, 0 turns it off) |
| Refresh Interval | No | 30 seconds | How often to check Plex (10-600 seconds) |

**Environment variables** (alternative to the UI settings):
Expand Down Expand Up @@ -122,6 +123,15 @@ This is how the plugin worked before 1.1.0, and it still does. A pasted token is
- If **Plex User** is set, it must match the name Plex shows for that user.
- The board updates on the refresh interval, so wait up to 30 seconds.

### The board goes blank between episodes

- When an episode ends, Plex stops the stream during its Up Next countdown and starts a new one for the next episode. The board keeps showing the last stream for **Hold Between Episodes** (60 seconds by default). If the countdown or the next episode's start takes longer than that, raise it.
- You don't need a short refresh interval for this. The default 30 seconds is fine.

### The board keeps showing something after I stop watching

- That is the **Hold Between Episodes** time. Lower it, or set it to 0 to switch to "Nothing playing" on the next refresh.

### Titles are cut off

- The display lines (`line_1`–`line_3`) are sized to the board. The full values are in `{{plex.title}}`, `{{plex.show}}` and the other media variables.
10 changes: 9 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "plex",
"name": "Plex Now Playing",
"version": "1.1.0",
"version": "1.2.0",
"description": "Show what's playing on your Plex Media Server: movies, TV episodes and music, laid out to fit any board",
"author": "FiestaBoard Team",
"repository": "https://github.com/Fiestaboard/fiestaboard-plugin--plex",
Expand Down Expand Up @@ -44,6 +44,14 @@
"description": "Frame the year or season/episode line with two yellow tiles on each side",
"default": true
},
"hold_seconds": {
"type": "integer",
"title": "Hold Between Episodes (seconds)",
"description": "When playback stops, keep showing it this long before switching to Nothing playing, so the board does not go blank during Plex's countdown to the next episode. 0 turns this off.",
"default": 60,
"minimum": 0,
"maximum": 600
},
"refresh_seconds": {
"type": "integer",
"title": "Refresh Interval (seconds)",
Expand Down
92 changes: 92 additions & 0 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ def test_env_vars_satisfy_required_settings(self, monkeypatch):
monkeypatch.setenv("PLEX_TOKEN", "env_token")
assert make_plugin().validate_config({}) == []

def test_hold_out_of_range(self):
errors = make_plugin().validate_config({"hold_seconds": 601})
assert errors == ["Hold must be between 0 and 600 seconds"]

def test_hold_not_a_number(self):
assert make_plugin().validate_config({"hold_seconds": "soon"}) == ["Hold must be between 0 and 600 seconds"]

def test_refresh_below_minimum(self):
errors = make_plugin().validate_config(
{"server_url": "http://plex.local:32400", "token": "t", "refresh_seconds": 5}
Expand Down Expand Up @@ -511,6 +518,91 @@ def test_invalid_json(self):
assert result.error == "Expecting value"


class TestHoldBetweenEpisodes:
"""Plex drops the session when an episode ends and starts the next after its Up Next countdown."""

@staticmethod
def poll(plugin, *items, at, board=None):
"""One uncached fetch at monotonic time *at*."""
plugin.clear_cache()
with patch("plugins.plex.requests.get", return_value=sessions_response(*items)), patch(
"plugins.plex.time.monotonic", return_value=at
):
return plugin.get_data(board)

def test_last_stream_is_held_through_a_gap(self):
plugin = make_plugin()
self.poll(plugin, episode_item(), at=1000)
result = self.poll(plugin, at=1030)
assert result.data["title"] == "Ozymandias"
assert result.data["state"] == "Playing"
assert result.data["stream_count"] == 1

def test_next_episode_replaces_the_held_one(self):
plugin = make_plugin()
self.poll(plugin, episode_item(), at=1000)
self.poll(plugin, at=1030)
result = self.poll(plugin, episode_item(index=15, title="Granite State"), at=1060)
assert result.data["title"] == "Granite State"

def test_idle_once_the_hold_runs_out(self):
plugin = make_plugin()
self.poll(plugin, episode_item(), at=1000)
self.poll(plugin, at=1030)
result = self.poll(plugin, at=1090)
assert result.data["state"] == "Idle"
assert result.data["line_2"] == "Nothing playing"

def test_hold_counts_from_the_first_empty_poll(self):
plugin = make_plugin(hold_seconds=20)
self.poll(plugin, episode_item(), at=1000)
assert self.poll(plugin, at=1100).data["title"] == "Ozymandias"
assert self.poll(plugin, at=1119).data["title"] == "Ozymandias"
assert self.poll(plugin, at=1120).data["state"] == "Idle"

def test_hold_restarts_after_playback_resumes(self):
plugin = make_plugin(hold_seconds=20)
self.poll(plugin, episode_item(), at=1000)
self.poll(plugin, at=1010)
self.poll(plugin, episode_item(index=15, title="Granite State"), at=1030)
result = self.poll(plugin, at=1045)
assert result.data["title"] == "Granite State"

def test_idle_stays_idle_after_the_hold(self):
plugin = make_plugin(hold_seconds=20)
self.poll(plugin, episode_item(), at=1000)
self.poll(plugin, at=1010)
self.poll(plugin, at=1030)
assert self.poll(plugin, at=1031).data["state"] == "Idle"

def test_zero_turns_the_hold_off(self):
plugin = make_plugin(hold_seconds=0)
self.poll(plugin, episode_item(), at=1000)
assert self.poll(plugin, at=1001).data["state"] == "Idle"

def test_nothing_to_hold_on_first_poll(self):
assert self.poll(make_plugin(), at=1000).data["state"] == "Idle"

def test_held_stream_is_laid_out_for_each_board(self):
plugin = make_plugin()
item = episode_item(grandparentTitle="The Marvelous Mrs. Maisel", parentIndex=1, index=1, title="Pilot")
self.poll(plugin, item, at=1000, board=NOTE)
result = self.poll(plugin, at=1030, board=NOTE_ARRAY_2_WIDE)
assert result.data["line_1"] == "The Marvelous Mrs. Maisel"

def test_stream_without_a_title_is_skipped(self):
"""A session Plex has not loaded the metadata for yet has nothing to show."""
result = self.poll(make_plugin(), episode_item(title=""), at=1000)
assert result.data["state"] == "Idle"
assert result.data["stream_count"] == 0

def test_stream_without_a_title_does_not_replace_the_held_one(self):
plugin = make_plugin()
self.poll(plugin, episode_item(), at=1000)
result = self.poll(plugin, episode_item(index=15, title=None), at=1030)
assert result.data["title"] == "Ozymandias"


class TestManifestContract:
@pytest.mark.parametrize(
"items", [(), (movie_item(),), (episode_item(),), (track_item(),)], ids=["idle", "movie", "episode", "track"]
Expand Down
6 changes: 4 additions & 2 deletions tests/test_sign_in.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,10 @@ def test_existing_settings_keys_kept_and_optional(self):
assert "token" not in MANIFEST["settings_schema"].get("required", [])
assert "server_url" not in MANIFEST["settings_schema"].get("required", [])

def test_minor_version_bump(self):
assert MANIFEST["version"] == "1.1.0"
def test_sign_in_is_not_a_breaking_release(self):
"""Sign in with Plex shipped in 1.1.0 without breaking 1.0.x settings."""
major, minor, _ = (int(part) for part in MANIFEST["version"].split("."))
assert (major, minor) >= (1, 1) and major == 1


class TestTokenChoice:
Expand Down
Loading