Skip to content
Open
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
155 changes: 50 additions & 105 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,109 +1,54 @@
# Plexio (natedogg058 fork)

> **This is a maintained fork of [vanchaxy/plexio](https://github.com/vanchaxy/plexio).**
> Upstream is dormant (last release May 2025). This fork adds fixes and improvements for self-hosted deployments.

## What's different from upstream

- **`behaviorHints.filename` on stream objects** — populates the Stremio-standard field used by clients for release fingerprinting (IntroDB skip intro, Trakt scrobbling, OpenSubtitles hash lookup). Closes a gap vs AIOStreams and other Stremio-standard addons. ([upstream PR #69](https://github.com/vanchaxy/plexio/pull/69))
- **Wider default CORS regex** — covers localhost on any port, private LAN ranges (`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`), Tailscale tailnet domains (`*.ts.net`), and `app.strem.io`. Reduces friction for self-hosted deployments behind reverse proxies or on Tailscale. `CORS_ORIGIN_REGEX` env var override is preserved.
- **`behaviorHints.videoSize` on stream objects** — exposes each version's file size so clients can display or choose by size. (0.3.1)
- **`BASE_URL` env var** — sets the public origin used for install-URL generation behind a reverse proxy / Tailscale Funnel, instead of relying on `window.location.origin`. (0.3.0)
- **Server-side sessions (optional)** — install URLs can reference a stored session id (`/{session_id}/...`) instead of embedding the full config (including the Plex token) as base64. Config is persisted in SQLite under `/data`; legacy base64 URLs continue to work unchanged. Requires a writable `/data` volume (see Installation). (0.4.0)
- **Encrypted sessions + revocation** — stored session config is Fernet-encrypted at rest (`SESSION_ENCRYPTION_KEY`, or an auto-generated `session.key` next to the DB). Operators can list and revoke sessions via admin-gated `GET` / `DELETE /api/v1/sessions` (`ADMIN_KEY`). (0.4.1)
- **Configure page uses sessions by default** — the configure UI now creates a server-side session on install and generates the short `/{session_id}/manifest.json` URL (Plex token never in the URL), automatically falling back to the legacy base64 URL if the session store is disabled or unreachable. (0.4.2)
- **Idempotent session creation** — submitting an identical config returns the existing session instead of minting a duplicate, keeping the admin session list clean (e.g. clicking clipboard then Install no longer creates two). (0.4.3)
- **Health endpoints** — `GET /api/v1/health` is a dependency-aware liveness probe (app + session store; 503 if the store is down), and `GET /api/v1/health/{session_id}` deep-checks whether that session's Plex backend is actually reachable (reachability only, never the token), so an uptime monitor can catch backend outages rather than just web-server outages. (0.5.0)
- **Continue Watching & Recently Added catalogs** — adds discovery rows to the Stremio board: "Continue Watching" (Plex On Deck — in-progress movies plus next-up/in-progress episodes, the latter surfaced as their parent series, deduped) and "Recently Added", each split into Movies / Shows and shown only for the library types you've configured. Catalog items resolve through the normal meta/stream flow (imdb-matched where Plex has the id). Discovery rows only — these don't feed Stremio's native Continue Watching bar, and a series row opens the show page rather than resuming the exact episode. (0.6.0)

## Installation

Pull the published image:
```bash
docker run -d -p 7777:80 -v plexio-data:/data ghcr.io/natedogg058/plexio:latest
# Plexio — CORS Fix Fork

This is a fork of [natedogg058/plexio](https://github.com/natedogg058/plexio) that fixes Plex auth on self-hosted instances.

## What was broken

Plex blocked direct browser calls to `plex.tv/api/v2/pins`, causing `code=undefined` in the OAuth redirect and breaking login on every self-hosted Plexio instance.

## What this fixes

Three Plex API calls are now proxied through the backend instead of the browser:
- PIN creation
- Token exchange
- Server list

## Quick start

```yaml
services:
plexio:
image: ghcr.io/senserpro/plexio:latest
container_name: plexio
restart: unless-stopped
volumes:
- plexio-data:/data
environment:
- CORS_ORIGIN_REGEX=https?:\/\/localhost:\d+|.*strem.io|.*stremio.com|.*YOUR-DOMAIN.com
- PLEX_REQUESTS_TIMEOUT=20
- CACHE_TYPE=redis
- REDIS_URL=redis://plexio-redis:6379/0
- BASE_URL=https://plexio.YOUR-DOMAIN.com
# Only needed if using a shared Plex server you do not own:
# - PLEX_MATCHING_TOKEN=your_plex_token_here
depends_on:
- redis

redis:
image: redis:alpine
container_name: plexio-redis
restart: unless-stopped
volumes:
- /opt/plexio/redis-data:/data

volumes:
plexio-data:
```
Or build from source with `docker build -t plexio-fork .`.

**Persistent storage (sessions):** the optional server-side session store keeps a SQLite DB at `/data/sessions.db`. The image creates `/data` owned by the `unit` app user (uid 999), so a Docker **named volume** (as above) inherits writable ownership automatically. If you bind-mount a host directory instead, `chown 999:999` it first. Disable the store entirely with `ENABLE_SESSIONS=false`, in which case no `/data` access is needed.

**Session env vars:** `ADMIN_KEY` enables and protects the list/revoke endpoints (unset = those endpoints return 403). `SESSION_ENCRYPTION_KEY` sets the Fernet key for encryption at rest; if unset, a key file is created automatically alongside the database.

## Roadmap

See [ISSUES](https://github.com/natedogg058/plexio/issues) for open work. Planned fork-specific additions:
- Documentation expansion for self-hosting behind reverse proxies
- Investigation of upstream toggle-default behaviour

---

*Original upstream README below.*

---
# Plexio: Plex Interaction for Stremio

⚠️ Plexio is an independent project and is not in any way affiliated with Plex or Stremio. ⚠️

Plexio is an addon that bridges the gap between Plex and Stremio, enabling seamless
integration of your Plex media within the Stremio interface. With Plexio, you can discover
and stream your Plex content directly in Stremio.

### Features
* offers both direct and transcoded streams;
* stream locally or from remote devices;
* allows searching through your Plex library;
* works with Cinemeta and other IMDB-based addons;
* handles media without IMDB matching;
* uses OAuth for safe login without sharing passwords;
* fully open-source with self-hosting support.


## Self-Hosting
If you'd prefer to self-host Plexio, you can do so easily using Docker. Follow these steps:

1. Use the following command to start a Plexio instance:
```bash
docker run -d -p 7777:80 ghcr.io/vanchaxy/plexio
```
2. Plexio addon will be available at http://localhost:7777/.

### Optional Configuration with Environment Variables
* *CORS_ORIGIN_REGEX*: A regex pattern to define allowed CORS origins
(default: `https?:\/\/localhost:\d+|.*plexio.stream|.*strem.io|.*stremio.com`).
* *PLEX_REQUESTS_TIMEOUT*: Timeout for Plex server requests in seconds (default: `20`).
* *CACHE_TYPE*: Defines the cache type to use `memory`/`redis` (default: `memory`).
* *REDIS_URL*: URL for a Redis instance if you use `redis` cache (default: `redis://redis:6399/0`).
* *PLEX_MATCHING_TOKEN*: Auth token for Plex media matching (default: `None`).
* *SENTRY_DSN*: DSN for error tracking with Sentry (default: `None`).

### Using addon with shared Plex server
If you are using Plexio with a Plex server that you do not own (you will see a "shared" badge
next to the server name), you must provide the `PLEX_MATCHING_TOKEN` environment variable.
This token is an access token from a Plex server you own, which will be used to
query the Plex API and resolve the Plex GUID using IMDB IDs.

To find your Plex authentication token, open any media on a Plex server you own.
Look for the XML data for the media and find the `X-Plex-Token` in the URL.
Copy the token from the URL.

You can learn more about finding your authentication token in the official Plex article
["Finding an authentication token"](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/).

## Local Development
1. Fork the Repository.
2. Clone the Repository:
```bash
git clone https://github.com/yourusername/plexio.git
```
3. Create a `.env` file and configure the required environment variables.
4. Run doker-compose:
```bash
docker-compose up --build
```

## Contacts

For bug reports, feature requests, or general questions, join our
[Discord support forum](https://discord.gg/8RWUkebmDs).
Replace `YOUR-DOMAIN.com` with your own domain. Add your reverse proxy labels (Traefik, Nginx Proxy Manager, etc.) as needed.

Alternatively, you can open an issue directly in this repository.
## Credits

- Original: [vanchaxy/plexio](https://github.com/vanchaxy/plexio)
- Maintained fork: [natedogg058/plexio](https://github.com/natedogg058/plexio)
- CORS fix: [senserpro/plexio](https://github.com/senserpro/plexio)
26 changes: 8 additions & 18 deletions frontend/src/services/PlexService.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,30 @@
import axios from 'axios';

const PLEX_PRODUCT_NAME = 'Plexio';
const PLEX_API_URL = 'https://plex.tv/api/v2';

export const createAuthPin = async (
clientIdentifier: string,
): Promise<AuthPin> => {
try {
const response = await axios.postForm(`${PLEX_API_URL}/pins`, {
strong: 'true',
'X-Plex-Product': PLEX_PRODUCT_NAME,
'X-Plex-Client-Identifier': clientIdentifier,
const response = await axios.post('/api/v1/plex-pin', null, {
headers: {
'X-Plex-Client-Identifier': clientIdentifier,
},
});

return response.data;
} catch (error) {
console.error('Error fetching users:', error);
console.error('Error fetching pin:', error);
throw error;
}
};

export const getAuthToken = async (
authPin: AuthPin,
clientIdentifier: string,
): Promise<string> => {
try {
const response = await axios.get(`${PLEX_API_URL}/pins/${authPin.id}`, {
const response = await axios.get(`/api/v1/plex-token/${authPin.id}`, {
params: {
code: authPin.code,
'X-Plex-Client-Identifier': clientIdentifier,
client_identifier: clientIdentifier,
},
});
return response.data.authToken;
Expand All @@ -37,7 +33,6 @@ export const getAuthToken = async (
throw error;
}
};

export const getPlexUser = async (
token: string,
clientIdentifier: string,
Expand All @@ -50,36 +45,31 @@ export const getPlexUser = async (
'X-Plex-Token': token,
},
});

if (response.status !== 200) {
return null;
}

return response.data;
} catch (error) {
console.error('Error fetching user:', error);
return null;
}
};

export const getPlexServers = async (
token: string,
clientIdentifier: string,
): Promise<PlexServer[]> => {
try {
const response = await axios.get(`${PLEX_API_URL}/resources`, {
const response = await axios.get('/api/v1/plex-resources', {
params: {
includeHttps: 1,
includeRelay: 1,
'X-Plex-Token': token,
'X-Plex-Client-Identifier': clientIdentifier,
},
});

if (!response.data || !Array.isArray(response.data)) {
throw new Error('Invalid response from server');
}

return response.data.filter(
(server: any) =>
server.provides.includes('server') && 'accessToken' in server,
Expand Down
2 changes: 2 additions & 0 deletions plexio/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from plexio.cache import init_cache
from plexio.routers.addon import router as addon_router
from plexio.routers.configuration import router as configuration_router
from plexio.routers.plex_proxy import router as plex_proxy_router
from plexio.sessions import init_sessions
from plexio.settings import settings

Expand Down Expand Up @@ -57,3 +58,4 @@ async def lifespan(app: FastAPI):

app.include_router(addon_router)
app.include_router(configuration_router)
app.include_router(plex_proxy_router)
64 changes: 64 additions & 0 deletions plexio/routers/plex_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
import asyncio
import urllib.request
import urllib.parse
import json

router = APIRouter()

PLEX_HEADERS = {
'X-Plex-Product': 'Plexio',
'X-Plex-Version': '1.0.0',
'Accept': 'application/json',
}

def _fetch_pin(client_id: str) -> dict:
req = urllib.request.Request(
'https://plex.tv/api/v2/pins?strong=true',
method='POST',
data=b'',
headers={**PLEX_HEADERS, 'X-Plex-Client-Identifier': client_id, 'Content-Length': '0'},
)
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode())

def _fetch_token(pin_id: str, code: str, client_id: str) -> dict:
params = urllib.parse.urlencode({'code': code, 'X-Plex-Client-Identifier': client_id})
url = f'https://plex.tv/api/v2/pins/{pin_id}?{params}'
req = urllib.request.Request(url, headers={**PLEX_HEADERS, 'X-Plex-Client-Identifier': client_id})
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode())

def _fetch_resources(token: str, client_id: str) -> list:
params = urllib.parse.urlencode({
'includeHttps': 1,
'includeRelay': 1,
'X-Plex-Token': token,
'X-Plex-Client-Identifier': client_id,
})
url = f'https://plex.tv/api/v2/resources?{params}'
req = urllib.request.Request(url, headers={**PLEX_HEADERS, 'X-Plex-Client-Identifier': client_id})
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode())

@router.post('/api/v1/plex-pin')
async def create_plex_pin(request: Request):
client_id = request.headers.get('X-Plex-Client-Identifier', '')
data = await asyncio.to_thread(_fetch_pin, client_id)
return JSONResponse(content=data)

@router.get('/api/v1/plex-token/{pin_id}')
async def get_plex_token(request: Request, pin_id: str, code: str = '', client_identifier: str = ''):
client_id = client_identifier or request.headers.get('X-Plex-Client-Identifier', '')
data = await asyncio.to_thread(_fetch_token, pin_id, code, client_id)
return JSONResponse(content=data)

@router.get('/api/v1/plex-resources')
async def get_plex_resources(request: Request):
# Read params by their actual names sent from the frontend
params = dict(request.query_params)
token = params.get('X-Plex-Token', '')
client_id = params.get('X-Plex-Client-Identifier', '')
data = await asyncio.to_thread(_fetch_resources, token, client_id)
return JSONResponse(content=data)