Lightweight self-hosted movie browser and streaming server with optional Plex integration.
Project description
Cat Theatre Movies Server
Lightweight self-hosted movie browser and streaming server built with Flask, Waitress, and
ffmpeg, with optionalPlexintegration for compatibility-focused playback.
Languages
English | 简体中文 | 繁體中文(香港) | 繁體中文(台灣) | Français | 한국어 | 日本語 | Deutsch | ไทย | Tiếng Việt | Nederlands
Overview
Cat Theatre is intentionally lightweight:
- small Python dependency surface
- no database requirement
- file-system-first cataloging
- portable polling-based scan flow instead of OS-specific watcher dependence
- optional Plex integration layered on top rather than required for core playback
It is designed for:
- local media libraries spread across one or more folders
- thumbnail and preview generation
- private-folder access control by device
- reverse-proxy deployment under a path prefix such as
/movie/ - mixed playback strategies: direct file playback, built-in local transcoding, or Plex-backed HLS
Features
- multi-root media scanning
- poster thumbnails and preview frame generation
- private folders with device-based unlock
- native direct playback for browser-safe formats
- built-in local transcoding for
.mkvand.tswhen enabled - Plex integration for playback, posters, subtitles, and HLS proxying
- context-path-aware routing for reverse proxies
- browser image caching plus IndexedDB metadata caching
UX And Playback Notes
- the built-in debug panel lives in the bottom-right and can slide to the nearest edge
- playback automatically prefers the safer path for the current file and device
- manual direct/Plex overrides are stored per video in IndexedDB
- cached thumbnails and metadata stay within browser storage limits
Requirements
Python
python -m pip install -e .
Current Python packages:
Flaskwaitress
System Binaries
Required for metadata probing, previews, thumbnails, and local transcoding:
ffmpegffprobe
Verify they are available:
which ffmpeg
which ffprobe
Quick Start
If you install the published PyPI package, use:
pip install plex-cat-theatre
plex-cat-theatre-init
plex-cat-theatre --config ~/movies_config.json
Preferred startup method:
./startup.sh
This bootstrap script can:
- create
movies_config.jsonfrom the sample config on first run - create a local
.venv - install Python dependencies into that local virtual environment
- create config-relative
cache/thumbnailsandlogsfolders when needed - check
ffmpegandffprobe - optionally help you generate the private-mode passcode hash
- start the server with your local config
You can still use the manual flow below:
- Copy the sample config:
cp movies_config.sample.json movies_config.json
-
Edit
movies_config.jsonfor your environment. -
Start the server:
python3 movies_server.py --config movies_config.json
- Open the UI:
http://localhost:9245
If you deploy the app behind a reverse proxy under a prefix such as /movie/, open the prefixed URL instead.
Project Structure
movies_server.py: Flask entrypoint and route wiringmovies_server_core.py: shared server helpers for auth, config, cookies, and mount-path handlingmovies_catalog.py: catalog scanning, thumbnail generation, subtitle extraction, and local transcode helpersmovies_server_plex.py: Plex adapter, poster/subtitle mapping, and Plex HLS proxyingmovies.js: frontend sourcemovies.min.js: minified frontend bundlemovies.css: gallery and player stylespasscode.py: helper for rotating the private-mode passcode
Release Versioning
Package versions are derived from Git tags.
- TestPyPI/testing: use a development version such as
2026.3.26.dev1 - PyPI prerelease: use a release candidate such as
2026.3.26rc1 - PyPI stable: use a stable version such as
2026.3.26 - Git tags should be
v2026.3.26.dev1,v2026.3.26rc1, andv2026.3.26
Configuration
The sample config is intentionally sanitized and does not include:
- real file-system paths
- real Plex tokens
- real passcodes
- device-specific values
Important Fields
root: media roots to scanthumbs_dir: directory for thumbnails and preview framesprivate_folder: folder prefixes treated as privateprivate_passcode: private-mode passcode hashmount_script: optional command used when playback hits a missing media foldertranscode: enable the catalog-side background transcode worker for source containers such as.mkvand.ts; this can generate separate transcoded sidecar media files alongside the source library, so it is usually best leftfalse, especially when Plex integration is enabledauto_scan_on_start: rescan media on startupon_demand_transcode: enable runtime player transcoding for source containers, using hardware encode when available and falling back to software encode when neededon_demand_hls: enable built-in HLS playlists for source containersenable_plex_server: enable Plex integrationplex.base_url: Plex server base URLplex.token: Plex tokendebug_enabled: show the built-in debug overlaydirect_playback: object withenabledandaudio_whitelist
Minimal Local-Only Example
{
"root": [
"~/Movies"
],
"thumbs_dir": "./cache/thumbs",
"mount_script": "",
"private_folder": [],
"private_passcode": "",
"on_demand_transcode": true,
"on_demand_hls": true,
"enable_plex_server": false,
"auto_scan_on_start": true
}
Plex-Integrated Example
{
"root": [
"~/Movies"
],
"thumbs_dir": "./cache/thumbs",
"mount_script": "",
"private_folder": [],
"private_passcode": "",
"on_demand_transcode": true,
"on_demand_hls": true,
"enable_plex_server": true,
"plex": {
"base_url": "http://127.0.0.1:32400",
"token": "REPLACE_WITH_YOUR_PLEX_TOKEN"
},
"auto_scan_on_start": true
}
Plex Scan Behavior
- local poster thumbnail generation is skipped when Plex posters are available
- existing cached local thumbnails can still be reused
- preview-frame generation remains enabled
- Plex integration stays optional and local-only mode still works
How To Get A Plex Token
Method 1: Existing Plex Web Session
- Open Plex Web and sign in.
- Open browser developer tools.
- Go to the Network tab.
- Refresh the page.
- Inspect a request sent to your Plex server.
- Find
X-Plex-Tokenin the URL or headers.
Method 2: Browser Storage
Check:
- Local Storage
- Session Storage
- request URLs and headers in DevTools
Method 3: Direct Local Request
If you already have an active Plex Web session on the same machine, inspect Plex requests in DevTools and look for:
X-Plex-Token=...
Security notes:
- treat the Plex token like a password
- do not commit it into git
- keep it only in
movies_config.json
Playback Modes
1. Native Direct Playback
Used for browser-safe files such as .mp4, .m4v, and .webm.
Behavior:
- serves the local file directly from
/video/<id> - supports HTTP range requests
- avoids transcoding overhead when the browser can play the file natively
Best for:
- MP4/H.264-style files
- browsers that already support the file directly
- files whose audio codecs match the direct-play whitelist
2. Built-In Local Transcoding Without Plex
This is the fallback path when Plex is not enabled, or when you intentionally want to stay fully local.
Current implementation:
.mkvand.tscan be exposed as local HLS at/hls/<id>/index.m3u8- the same files can also be streamed as fragmented MP4 from
/video/<id>?fmp4=1 - HLS segments are generated on demand with
ffmpeg - hardware encode can be tried first and fall back to
libx264 - fMP4 output is generated with
libx264plus AAC
3. Plex-Backed Playback
When Plex integration is enabled:
- the frontend can use
plex_stream_urlfor compatibility-sensitive playback - Plex generates the upstream HLS playlist
- this server rewrites the playlist and proxies nested playlist and segment requests
- the browser still talks to this app, not directly to Plex
Best for:
- MKV or TS content on devices with weaker codec or container support
- cases where Plex subtitle selection or stream normalization is preferred
Playback Selection Policy
- direct playback wins for browser-safe files whose audio codecs match
direct_playback.audio_whitelist - Plex remains preferred for
.mkv,.ts, HLS, fMP4, or unsupported audio codecs - iOS-native HLS fallback timing is longer so the Plex stream has time to warm up
Default Playback Logic
Directis preferred for.mp4,.m4v,.webm, and.aviwhen the direct URL is a real file path and the audio codecs are whitelist-safe- if audio codec metadata is missing for one of those browser-safe extensions, the app still prefers
Direct Plexis preferred for.mkv,.ts, HLS/fMP4 direct URLs, and files whose known audio codecs fall outside the whitelist- if no Plex match exists, the app falls back to
Direct
Debug Overlay
Enable debug_enabled in movies_config.json to keep a permanent debug overlay in the lower-right corner.
The panel reports:
- whether the server is favoring direct playback or Plex
- the configured direct-play audio whitelist
- the current playback candidate and video ID
- recent scan progress metrics
Inspect active config values with:
curl -s http://localhost:9245/api/config | python3 -m json.tool
If you serve the app under /movie/, use the prefixed path instead.
Authentication Model
The app uses different transport methods depending on request type:
- API requests use the
X-Device-Idheader - HLS and Plex proxy requests use the
X-Device-Idheader - native direct media requests use the
movies_device_idcookie fallback
This split exists because native <video src="..."> requests cannot attach arbitrary custom headers.
Reverse Proxy And Context Path Support
The app supports deployment under subpaths such as:
https://example.com/movie/https://example.com/cinema/
Routing preserves the active mount prefix for:
- direct media
- local HLS
- Plex HLS proxy requests
- poster and subtitle assets
Remote Plex Access With Tailscale
If the custom UI is reachable remotely but Plex is only reachable on a private LAN, the movies server host must still be able to reach the Plex backend directly.
Same Host
"plex": {
"base_url": "http://127.0.0.1:32400"
}
Plex On Another LAN Machine
Advertise the route from a Tailscale node that can reach Plex:
sudo tailscale up --advertise-routes=192.168.50.0/24
Then verify reachability from the movies server host:
curl http://192.168.50.10:32400/identity
Notes:
- the browser does not need direct network access to Plex
- the movies server process must be able to reach
plex.base_url - reverse-proxy or MagicDNS names for the UI do not make Plex reachable by themselves
Caching Strategy
Image Caching
Thumbnails, preview frames, and Plex poster images are served with long-lived immutable cache headers.
Metadata Caching
Gallery metadata snapshots are cached in IndexedDB with bounded storage:
- 1-day TTL
- up to 8 snapshot records
- up to about 18 MB estimated total size
- older entries evicted when limits are exceeded
Each cached snapshot stores:
- server
catalogStatus - folder list cache
- loaded
videos - pagination counters such as
serverTotal,serverOffset, andserverExhausted
Eviction is opportunistic rather than scheduled:
- expired entries are removed on read or later pruning
- pruning runs after fresh snapshots are saved
- browser storage pressure or manual site-data clearing can also remove IndexedDB data
Scan Behavior
The catalog scan is designed to stay incremental in cost even though it still walks each configured root.
Current behavior:
- unchanged files reuse cached
mtime + sizesignatures - periodic scans no longer sort the full path list before processing
- deleted files are removed from the in-memory catalog and persisted index
- deleted files also trigger cleanup of generated thumbnail and preview artifacts
- index saves reuse cached file signature data instead of statting every file again
What the scan still does:
- walks configured media roots to detect added, changed, and deleted files
- queues preview generation when preview images are missing
What it does not do:
- it does not checksum large media files during periodic scans
- it does not regenerate thumbnails or metadata for unchanged files unless cached artifacts are missing
Force Full Rescan
Use:
/rescan?full=1
This is useful when:
- someone manually deleted the thumbnail or preview cache folder
- you suspect the saved scan manifest is stale
- you want to force full revalidation of scan-derived state
Check Scan Status
curl -s http://localhost:9245/api/status | python3 -m json.tool
If you serve the app under /movie/, use the prefixed path.
Trigger Rescan
Normal incremental rescan:
curl -s http://localhost:9245/rescan | python3 -m json.tool
Forced full rescan:
curl -s "http://localhost:9245/rescan?full=1" | python3 -m json.tool
Rescan UI
The Rescan button opens an action dialog instead of immediately starting an incremental scan.
Available actions:
Rescan: incremental scan for new or changed filesFull Scan: clears saved scan state and forces full metadata revalidationRefresh Database: clears browser IndexedDB snapshots and reloads fresh catalog data
Missing Mount Recovery
If mount_script is configured and a media request hits a missing folder, the server will:
- detect that the parent folder does not exist
- invoke the configured mount script once
- re-check the target path
- return
Media folder is not mountedwith HTTP 404 only if the folder is still unavailable
The frontend treats playback 404s as terminal for that attempt and shows a retry message instead of repeatedly hammering the server.
Frontend Development Notes
The app currently loads movies.js directly from index.html, so frontend changes take effect without rebuilding movies.min.js.
Private Mode
- private folders are hidden unless the device is authorized
- unlock state is tied to a device ID
- approved devices are stored server-side
passcode.pycan rotate the private-mode passcode and clear approvals
Example:
python3 passcode.py mynewpasscode
Generated Files
These files are runtime-generated and should not be committed:
movies_config.jsonmovies_state.jsonmovies_auth_state.jsonmovies_catalog_index.jsoncache/
Troubleshooting
UI Changes Do Not Appear
- refresh the page normally first
- if the JS bundle changed, confirm
index.htmlreferences the expected bundle version
Direct Private Playback Fails
- unlock private mode again so the
movies_device_idcookie is refreshed
Plex Playback Fails But Direct Playback Works
- verify the movies server host can reach
plex.base_url - verify Plex is enabled in config
- verify the configured token is valid
Direct Playback Fails But Plex Works
- the container or codec is likely not safe for native browser playback on that device
- keep Plex enabled for those files, or force the compatibility path through local transcode or Plex
Local Transcoding Does Not Work
- verify
ffmpegandffprobeare installed - verify
on_demand_transcodeis enabled - verify the source file is one of the currently supported containers:
.mkvor.ts
License
This project is released under the MIT License. Add a LICENSE file containing the MIT text when publishing or redistributing it.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file plex_cat_theatre-2026.3.27.tar.gz.
File metadata
- Download URL: plex_cat_theatre-2026.3.27.tar.gz
- Upload date:
- Size: 254.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
000252c1a14432d2b8cf591709fd75a1c61840cc33587267b8243bd49497d62a
|
|
| MD5 |
014ba6ef33b9d276fc901f0e833a0782
|
|
| BLAKE2b-256 |
b44d184d4bf58d018092c70b043a23cc4122259c1996670fff6c3924cd4561ca
|
Provenance
The following attestation bundles were made for plex_cat_theatre-2026.3.27.tar.gz:
Publisher:
python-publish.yml on daocha/plex-cat-theatre
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
plex_cat_theatre-2026.3.27.tar.gz -
Subject digest:
000252c1a14432d2b8cf591709fd75a1c61840cc33587267b8243bd49497d62a - Sigstore transparency entry: 1186449012
- Sigstore integration time:
-
Permalink:
daocha/plex-cat-theatre@8d46306d670aeed4ac7f1a6638eb9b5136a902a6 -
Branch / Tag:
refs/tags/v2026.3.27 - Owner: https://github.com/daocha
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@8d46306d670aeed4ac7f1a6638eb9b5136a902a6 -
Trigger Event:
release
-
Statement type:
File details
Details for the file plex_cat_theatre-2026.3.27-py3-none-any.whl.
File metadata
- Download URL: plex_cat_theatre-2026.3.27-py3-none-any.whl
- Upload date:
- Size: 139.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
70d7fab0df77e45aa83aff10f5cba60eb91e6a4dd08260fb888c1bab6a9cf81e
|
|
| MD5 |
cdc683075f958d2b820046b84d4f1436
|
|
| BLAKE2b-256 |
3b77c315aa20269d48fed2dc46cea130bbd2497143e0fba7f458f021486cc3b6
|
Provenance
The following attestation bundles were made for plex_cat_theatre-2026.3.27-py3-none-any.whl:
Publisher:
python-publish.yml on daocha/plex-cat-theatre
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
plex_cat_theatre-2026.3.27-py3-none-any.whl -
Subject digest:
70d7fab0df77e45aa83aff10f5cba60eb91e6a4dd08260fb888c1bab6a9cf81e - Sigstore transparency entry: 1186449014
- Sigstore integration time:
-
Permalink:
daocha/plex-cat-theatre@8d46306d670aeed4ac7f1a6638eb9b5136a902a6 -
Branch / Tag:
refs/tags/v2026.3.27 - Owner: https://github.com/daocha
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@8d46306d670aeed4ac7f1a6638eb9b5136a902a6 -
Trigger Event:
release
-
Statement type: