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
56 changes: 56 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Build & Publish Docker Image

on:
push:
branches: [main]
tags: ["v*"]

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@
dist/
.wrangler/
*.log

# Runtime data — never commit
secret.key
*.db
__pycache__/
venv/
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
FROM python:3.12-slim

LABEL maintainer="Inch-high"
LABEL org.opencontainers.image.title="PlexGeo"
LABEL org.opencontainers.image.description="Stream intelligence dashboard for Plex — live map, session logs, anomaly alerts"
LABEL org.opencontainers.image.source="https://github.com/Inch-high/plexgeo"
LABEL org.opencontainers.image.url="https://github.com/Inch-high/plexgeo"

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
Expand All @@ -15,4 +21,7 @@ ENV DB_PATH=/data/plexgeo.db

EXPOSE 7842

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7842/health')" || exit 1

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7842"]
48 changes: 44 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,27 @@ Self-hosted Docker app that monitors your Plex server, logs every user stream by

### 1. Get your Plex token

Open Plex Web → any media item → `···` → Get Info → View XML.
Open Plex Web → any media item → `···` → Get Info → View XML.
The URL will contain `X-Plex-Token=XXXX` — copy that value.

Or follow: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/

### 2. Configure
### 2. Generate an encryption key

```bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```

Save the output — you'll need it for the `ENCRYPTION_KEY` variable.

### 3. Configure

```bash
cp .env.example .env
# Edit .env with your PLEX_URL and PLEX_TOKEN
# Edit .env with your PLEX_URL, PLEX_TOKEN, and ENCRYPTION_KEY
```

### 3. Run
### 4. Run

```bash
docker compose up -d
Expand All @@ -46,11 +54,40 @@ Dashboard will be at **http://localhost:7842**
|---|---|---|
| `PLEX_URL` | — | URL of your Plex server, e.g. `http://192.168.1.50:32400` |
| `PLEX_TOKEN` | — | Your Plex authentication token |
| `ENCRYPTION_KEY` | — | Fernet key for encrypting sensitive settings at rest (see above) |
| `PORT` | `7842` | Host port for the dashboard |
| `POLL_INTERVAL` | `30` | Seconds between Plex polls |
| `OUTLIER_THRESHOLD` | `0.10` | A country must account for <10% of sessions to be flagged |
| `OUTLIER_MIN_SESSIONS` | `5` | Minimum sessions before outlier detection kicks in |

> If `ENCRYPTION_KEY` is not set, a temporary key is generated in memory. Encrypted settings (e.g. your Plex token stored via the UI) will be lost on container restart.

---

## Docker Image

Pre-built images are published to GHCR on every push to `main`:

```bash
docker pull ghcr.io/inch-high/plexgeo:latest
```

Available for `linux/amd64` and `linux/arm64`.

---

## Unraid

1. In the Unraid Docker tab, click **Add Container**
2. Set **Repository** to `ghcr.io/inch-high/plexgeo:latest`
3. Add a **Port** mapping: host `7842` → container `7842`
4. Add a **Path** mapping: host `/mnt/user/appdata/plexgeo` → container `/data`
5. Add **Variables**:
- `PLEX_URL` = your Plex server URL
- `PLEX_TOKEN` = your Plex token
- `ENCRYPTION_KEY` = your generated Fernet key
6. Click **Apply**

---

## Outlier Detection Logic
Expand All @@ -71,6 +108,7 @@ For each new session:
- All data stored locally in a Docker volume (`plexgeo_data`)
- IP addresses are sent to [ip-api.com](https://ip-api.com) for geolocation, then cached in SQLite (subsequent lookups don't re-query)
- Local/private IPs (192.168.x.x etc.) are detected and never sent externally — labelled "Local Network"
- Sensitive settings (Plex token) are encrypted at rest using Fernet symmetric encryption
- No external telemetry

---
Expand All @@ -85,6 +123,8 @@ plexgeo/
├── .env.example
└── app/
├── main.py # FastAPI app + scheduler
├── config.py # Settings management + encryption
├── crypto.py # Fernet encryption (key from ENCRYPTION_KEY env)
├── database.py # SQLite schema + queries
├── plex_poller.py # Plex session polling
├── geo.py # ip-api.com geolocation + cache
Expand Down
40 changes: 21 additions & 19 deletions app/crypto.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,40 @@
"""
Fernet symmetric encryption for sensitive settings.

The encryption key is generated once and stored at KEY_PATH (default /data/secret.key).
In dev, KEY_PATH follows DB_PATH so the key lives alongside dev.db.
The encryption key is read from the ENCRYPTION_KEY environment variable.
If not set, a transient key is generated in memory (will not survive restarts,
meaning encrypted settings will become unreadable after a container restart).
"""
import os
import stat
import logging
from pathlib import Path
from cryptography.fernet import Fernet

logger = logging.getLogger(__name__)

# Derive key path from DB_PATH so they always live together
_db_path = os.environ.get("DB_PATH", "/data/plexgeo.db")
KEY_PATH = os.environ.get("KEY_PATH", str(Path(_db_path).parent / "secret.key"))
_key: bytes | None = None


def _load_or_create_key() -> bytes:
path = Path(KEY_PATH)
if path.exists():
return path.read_bytes().strip()
def _get_key() -> bytes:
global _key
if _key is not None:
return _key

key = Fernet.generate_key()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(key)
# Restrict to owner read/write only
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
logger.info(f"Generated new encryption key at {KEY_PATH}")
return key
env_key = os.environ.get("ENCRYPTION_KEY", "").strip()
if env_key:
_key = env_key.encode()
else:
_key = Fernet.generate_key()
logger.warning(
"ENCRYPTION_KEY not set — using an ephemeral key. "
"Encrypted settings will be LOST on restart. "
"Generate a permanent key with: "
'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
)
return _key


def _fernet() -> Fernet:
return Fernet(_load_or_create_key())
return Fernet(_get_key())


def encrypt(plaintext: str) -> str:
Expand Down
Binary file removed dev.db
Binary file not shown.
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
services:
plexgeo:
image: ghcr.io/inch-high/plexgeo:latest
build: .
container_name: plexgeo
restart: unless-stopped
Expand All @@ -11,6 +12,8 @@ services:
- POLL_INTERVAL=${POLL_INTERVAL:-30}
- OUTLIER_THRESHOLD=${OUTLIER_THRESHOLD:-0.10}
- OUTLIER_MIN_SESSIONS=${OUTLIER_MIN_SESSIONS:-5}
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
volumes:
- plexgeo_data:/data

Expand Down
Loading