diff --git a/README.md b/README.md index 0461de6..9c4bd5f 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 diff --git a/__init__.py b/__init__.py index e23785c..bd5e169 100644 --- a/__init__.py +++ b/__init__.py @@ -11,6 +11,7 @@ import math import os import textwrap +import time import uuid from typing import Any, Dict, List, Optional, Tuple @@ -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", @@ -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: @@ -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) @@ -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] diff --git a/docs/SETUP.md b/docs/SETUP.md index 9e537b7..ccadcae 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -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): @@ -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. diff --git a/manifest.json b/manifest.json index 3fbe73b..0d94f49 100644 --- a/manifest.json +++ b/manifest.json @@ -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", @@ -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)", diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 920cffe..6324979 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -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} @@ -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"] diff --git a/tests/test_sign_in.py b/tests/test_sign_in.py index d57c573..2007f6f 100644 --- a/tests/test_sign_in.py +++ b/tests/test_sign_in.py @@ -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: