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
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Show what's playing on your Plex Media Server — movies, TV episodes and music

![Plex Now Playing Display](./docs/board-display.png)

**→ [Setup Guide](./docs/SETUP.md)** - Finding your Plex token and configuration
**→ [Setup Guide](./docs/SETUP.md)** - Signing in with Plex (or pasting a token) and configuration

## Overview

Expand Down Expand Up @@ -95,15 +95,22 @@ Center all three lines:

| Setting | Type | Required | Default | Description |
|---------|------|----------|---------|-------------|
| `server_url` | string | Yes | - | Plex server address, e.g. `http://192.168.1.100:32400` |
| `token` | string | Yes | - | Your X-Plex-Token |
| `server_url` | string | No | - | Plex server address, e.g. `http://192.168.1.100:32400`. Empty: found through your Plex account |
| `token` | string | No | - | An X-Plex-Token to use instead of **Sign in with Plex**. When set, it wins |
| `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 |
| `refresh_seconds` | integer | No | `30` | How often to ask Plex what's playing (10-600) |
| `enabled` | boolean | No | `false` | Enable the plugin |

`PLEX_URL` and `PLEX_TOKEN` environment variables can be used instead of the UI settings.

### Sign in with Plex

Requires FiestaBoard 9.11.0 or later. Click **Sign in with Plex** in the plugin's settings and approve FiestaBoard on Plex's page; nothing to copy. A pasted token (or `PLEX_TOKEN`) still works and takes priority over the sign-in. If Plex stops accepting the sign-in, the settings ask you to sign in again.

With no `server_url`, the plugin asks plex.tv for the servers on your account (`/api/v2/resources`), prefers one you own, and tries its home-network address first, then its remote address, then Plex's relay. This works with a pasted token too.

## Features

- Movies, TV episodes, and music
Expand All @@ -113,7 +120,8 @@ Center all three lines:
- Filter to a single Plex user
- Every active stream available as `sessions`, plus a stream count
- Playback state color-coded: green playing, yellow paused, orange buffering
- Talks to your server directly on your network; no Plex account API or cloud relay
- Sign in with Plex, or paste a token
- Finds your server through your Plex account, or talks to the address you give it directly

## Author

Expand Down
160 changes: 143 additions & 17 deletions __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 uuid
from typing import Any, Dict, List, Optional, Tuple

import requests
Expand All @@ -23,6 +24,13 @@
DEFAULT_ROWS = 6
ACCENT = "{65}{65}" # two yellow tiles, Plex's brand colour
REQUEST_TIMEOUT = 10
PROBE_TIMEOUT = 3
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"

STATE_LABELS = {
"playing": "Playing",
Expand Down Expand Up @@ -199,9 +207,42 @@ def pick_primary(sessions: List[Dict[str, Any]]) -> Dict[str, Any]:
return min(sessions, key=lambda s: STATE_PRIORITY.index(s["state"]))


def candidate_uris(resource: Dict[str, Any]) -> List[str]:
"""A server's connection addresses, best first: on the home network, then remote, then Plex's relay."""
connections = [c for c in resource.get("connections") or [] if isinstance(c, dict) and c.get("uri")]
connections.sort(key=lambda c: (bool(c.get("relay")), not c.get("local")))
return [c["uri"].rstrip("/") for c in connections]


def pick_servers(resources: Any, server_name: str) -> List[Dict[str, Any]]:
"""The Plex Media Servers in a plex.tv resources answer, owned ones first.

Raises LookupError when there is none (or none with *server_name*).
"""
if not isinstance(resources, list):
raise ValueError("plex.tv gave an unexpected answer")
servers = [
r for r in resources if isinstance(r, dict) and "server" in str(r.get("provides") or "").split(",")
]
if server_name:
servers = [s for s in servers if str(s.get("name") or "").strip().lower() == server_name.lower()]
if not servers:
raise LookupError(f"No Plex server named {server_name} on this Plex account")
if not servers:
raise LookupError(f"No Plex server found on this Plex account; {SET_SERVER_URL}")
servers.sort(key=lambda s: (not s.get("owned"), not s.get("presence", True)))
return servers


class PlexPlugin(PluginBase):
"""Plex Now Playing plugin."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
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

@property
def plugin_id(self) -> str:
return "plex"
Expand All @@ -215,49 +256,134 @@ def _token(config: Dict[str, Any]) -> str:
return (config.get("token") or os.getenv("PLEX_TOKEN", "")).strip()

def validate_config(self, config: Dict[str, Any]) -> List[str]:
# Both are optional: Sign in with Plex supplies a token, and plex.tv finds the server.
errors = []

server_url = self._server_url(config)
if not server_url:
errors.append("Plex server URL is required")
elif not server_url.startswith(("http://", "https://")):
if server_url and not server_url.startswith(("http://", "https://")):
errors.append("Plex server URL must start with http:// or https://")

if not self._token(config):
errors.append("Plex token is required")

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

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)
if not callable(getter):
return ""
try:
return (getter() or "").strip()
except Exception as e: # never let the sign-in store break a fetch
logger.warning("Could not read the Plex sign-in: %s", e)
return ""

def _report_rejected(self) -> None:
report = getattr(self, "report_oauth_rejected", None)
if callable(report):
report()

def _headers(self, token: str) -> Dict[str, str]:
"""Headers for the Plex server: exactly what 1.0.x sent."""
return {
"X-Plex-Token": token,
"X-Plex-Product": "FiestaBoard",
"Accept": "application/json",
}

def _plex_tv_headers(self, token: str) -> Dict[str, str]:
# plex.tv's v2 API needs a client identifier; the server does not, and would list each new one as a device.
return {**self._headers(token), "X-Plex-Client-Identifier": self._client_identifier}

def _reachable(self, uri: str, token: str) -> bool:
try:
return requests.get(f"{uri}/identity", headers=self._headers(token), timeout=PROBE_TIMEOUT).status_code == 200
except requests.exceptions.RequestException:
return False

def _discover_server(self, token: str) -> Tuple[str, str]:
"""(server_url, server_token) for the account's server, found through plex.tv and cached."""
server_name = (self.config.get("server_name") or "").strip()
key = (token, server_name)
if self._discovered and self._discovered[0] == key:
return self._discovered[1], self._discovered[2]

response = requests.get(
PLEX_RESOURCES_URL,
params={"includeHttps": 1, "includeRelay": 1},
headers=self._plex_tv_headers(token),
timeout=REQUEST_TIMEOUT,
)
if response.status_code == 401:
raise PermissionError(TOKEN_REJECTED)
response.raise_for_status()
for resource in pick_servers(response.json(), server_name):
server_token = resource.get("accessToken") or token
for uri in candidate_uris(resource):
if self._reachable(uri, server_token):
self._discovered = (key, uri, server_token)
return uri, server_token
raise LookupError(f"Found your Plex server but the board cannot reach it; {SET_SERVER_URL}")

def _fetch_sessions(self, server_url: str, token: str) -> List[Dict[str, Any]]:
response = requests.get(
f"{server_url}/status/sessions",
headers={
"X-Plex-Token": token,
"X-Plex-Product": "FiestaBoard",
"Accept": "application/json",
},
headers=self._headers(token),
timeout=REQUEST_TIMEOUT,
)
if response.status_code == 401:
raise PermissionError("Plex rejected the token (401 Unauthorized)")
raise PermissionError(TOKEN_REJECTED)
response.raise_for_status()
container = response.json().get("MediaContainer") or {}
return container.get("Metadata") or []

def _rejected(self, signed_in: bool) -> PluginResult:
if signed_in:
self._report_rejected()
return PluginResult(available=False, error=SIGN_IN_REJECTED)
return PluginResult(available=False, error=TOKEN_REJECTED)

def _locate_server(self, token: str, signed_in: bool) -> Tuple[Optional[Tuple[str, str]], Optional[PluginResult]]:
"""The server to ask (from settings, or found through plex.tv), or the result explaining why not."""
server_url = self._server_url(self.config)
if server_url:
return (server_url, token), None
try:
return self._discover_server(token), None
except PermissionError:
return None, self._rejected(signed_in)
except LookupError as e:
return None, PluginResult(available=False, error=str(e))
except (requests.exceptions.RequestException, ValueError) as e:
logger.warning("Could not look up the Plex server at plex.tv: %s", e)
return None, PluginResult(available=False, error=f"Could not find your Plex server through plex.tv: {e}")

def fetch_data(self) -> PluginResult:
"""Fetch the active Plex sessions and build the template variables."""
server_url = self._server_url(self.config)
# A pasted token (or PLEX_TOKEN) wins over Sign in with Plex.
token = self._token(self.config)
if not server_url or not token:
return PluginResult(available=False, error="Plex server URL or token not configured")
signed_in = False
if not token:
token = self._signed_in_token()
signed_in = bool(token)
if not token:
return PluginResult(available=False, error=NEEDS_TOKEN)

located, failure = self._locate_server(token, signed_in)
if failure:
return failure
server_url, server_token = located

try:
items = self._fetch_sessions(server_url, token)
items = self._fetch_sessions(server_url, server_token)
except requests.exceptions.RequestException as e:
self._discovered = None
logger.warning("Could not reach Plex at %s: %s", server_url, e)
return PluginResult(available=False, error=f"Could not reach Plex server: {e}")
except (PermissionError, ValueError) as e:
except PermissionError:
self._discovered = None
# Only the account token itself proves the sign-in is dead; a server's shared token can be revoked alone.
return self._rejected(signed_in and server_token == token)
except ValueError as e:
logger.warning("Plex request failed: %s", e)
return PluginResult(available=False, error=str(e))

Expand Down
54 changes: 40 additions & 14 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,17 @@ Connect FiestaBoard to your Plex Media Server so your board shows what's playing

**Prerequisites:**

- A Plex Media Server that FiestaBoard can reach on your network
- The server's address (usually `http://<server-ip>:32400`)
- A Plex token (X-Plex-Token) for an account with access to the server
- A Plex Media Server that FiestaBoard can reach
- A Plex account with access to the server
- FiestaBoard 9.11.0 or later for **Sign in with Plex** (on older versions, paste a token instead)

## Quick Setup

1. **Find your Plex token**
1. Open Plex Web (app.plex.tv) and sign in with an account on your server.
2. Open any movie or episode in your library.
3. Click the **⋯** menu and choose **Get Info**, then **View XML**.
4. The XML opens in a new tab. Copy the value after `X-Plex-Token=` at the end of its address.

Treat the token like a password: it grants access to your Plex account.
1. **Enable** — In FiestaBoard, go to **Integrations**, find **Plex Now Playing**, and turn it on.

2. **Enable** — In FiestaBoard, go to **Integrations**, find **Plex Now Playing**, and turn it on.
2. **Sign in** — In the plugin's settings, click **Sign in with Plex**. Plex's sign-in page opens in a new tab; sign in and approve **FiestaBoard**. The settings show you are connected once Plex confirms, usually within a few seconds.

3. **Configure** — Enter your **Plex Server URL** (for example `http://192.168.1.100:32400`) and paste your **Plex Token**. Optionally set a **Plex User** to only show that person's streams.
3. **Server (usually nothing to do)** — Leave **Plex Server URL** empty and the plugin finds your server through your Plex account, trying its home-network address first. If your account can reach several servers, enter the one you want in **Plex Server Name**. If the board cannot reach the server that way, enter its address in **Plex Server URL** (for example `http://192.168.1.100:32400`). Optionally set a **Plex User** to only show that person's streams.

4. **Template** — Create a page from the plugin's demo, or add the display lines to your own page:

Expand All @@ -38,6 +32,20 @@ Connect FiestaBoard to your Plex Media Server so your board shows what's playing

5. **View** — Start playing something in Plex. The board updates on the next refresh (30 seconds by default).

### Instead of signing in: paste a token

This is how the plugin worked before 1.1.0, and it still does. A pasted token is used instead of the sign-in whenever it is set.

1. **Find your Plex token**
1. Open Plex Web (app.plex.tv) and sign in with an account on your server.
2. Open any movie or episode in your library.
3. Click the **⋯** menu and choose **Get Info**, then **View XML**.
4. The XML opens in a new tab. Copy the value after `X-Plex-Token=` at the end of its address.

Treat the token like a password: it grants access to your Plex account.

2. Paste it into **Plex Token**. **Plex Server URL** can stay empty, as above, or hold your server's address.

## Template Variables

| Variable | Description |
Expand Down Expand Up @@ -65,8 +73,9 @@ Connect FiestaBoard to your Plex Media Server so your board shows what's playing

| Setting | Required | Default | Description |
|---------|----------|---------|-------------|
| Plex Server URL | Yes | - | Your server's address, including the port |
| Plex Token | Yes | - | Your X-Plex-Token |
| Plex Server URL | No | - | Your server's address, including the port. Empty: found through your Plex account |
| Plex Token | No | - | An X-Plex-Token, used instead of Sign in with Plex when set |
| 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 |
| Refresh Interval | No | 30 seconds | How often to check Plex (10-600 seconds) |
Expand All @@ -86,6 +95,23 @@ Connect FiestaBoard to your Plex Media Server so your board shows what's playing
- FiestaBoard must be able to reach the server. If FiestaBoard runs in Docker, use the server's LAN IP, not `localhost`.
- Using `https://` with a plain IP address fails the certificate check. Use `http://` on your local network. If your server's **Secure connections** setting is **Required**, change it to **Preferred**.

### "Sign in with Plex, or paste a Plex token, in the plugin settings"

- Click **Sign in with Plex** in the settings, or paste a token.

### "Plex stopped accepting the sign-in. Sign in with Plex again."

- Plex refused the signed-in token, for example after **Sign out of all devices** or removing FiestaBoard from your Plex devices. Click **Sign in with Plex** again.

### "No Plex server found on this Plex account" or "the board cannot reach it"

- Make sure the server is signed in to the same Plex account (or shared with it) and online.
- If the board still cannot reach it, enter the server's address in **Plex Server URL**.

### "Could not find your Plex server through plex.tv"

- The board could not reach plex.tv. Check its internet connection, or enter **Plex Server URL** to talk to the server directly.

### "Plex rejected the token (401 Unauthorized)"

- The token is wrong or has expired. Signing out of all devices in Plex resets tokens, so find it again and paste the new one.
Expand Down
Loading
Loading