Lightweight self-hosted movie browser and streaming server with optional Plex integration.
Project description
Cat Theatre Movies Server 🐱
English | Deutsch | Français | 日本語 | 한국어 | Nederlands | ไทย | Tiếng Việt | 简体中文 | 繁體中文(香港) | 繁體中文(台灣)
Super lightweight, Privacy Mode 🔐, Cross-device, Smart Streaming
No App is required. Easy server installation. Mobile friendly user interface, connecting your NAS anywhere, with optional PLEX integration
No heavy dependency, everything transparent. Lightweight self-hosted movie browser and streaming server built with Flask, Waitress, and
ffmpeg, with optionalPlexintegration for compatibility-focused playback.
✨ Why Use It
Cat Theatre is intentionally lightweight:
- 🩷 No Plex subscription 💰 is reuqired for Remote Access
- ✅ Small Python dependency surface
- ✅ No database requirement
- ✅ File-system-first cataloging
- ✅ Compatible with 🖥️ Desktop, 📱 Mobile, Tablet
- ✅ Portable polling-based scan flow instead of OS-specific watcher dependence
- 🔶 Optional Plex integration layered on top rather than required for core playback
✴️ Features
- 🎬 Local / NAS media libraries spread across multiple folders
- 🌄 Thumbnail / Poster and preview generation
- 🔐 Private-folder with device-based unlock
- 🔗 Reverse-proxy deployment under a path prefix such as
http://192.168.1.100/movie/ - 📽️ Mixed playback strategies: direct playback, built-in local transcoding for
.mkvand.ts, or Plex-backed HLS proxying. Easy switch per media - 🌐 Browser image caching plus IndexedDB metadata caching
→ Setup with one-liner:
curl -fsSL https://raw.githubusercontent.com/daocha/plex-cat-theatre/main/install.sh | bash
🟢 Requirements
Python 3.9 or newer
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
→ Option A: Setup with one-liner:
curl -fsSL https://raw.githubusercontent.com/daocha/plex-cat-theatre/main/install.sh | bash
→ Option B: Install from PyPI with pip
pip install plex-cat-theatre
plex-cat-theatre-init
plex-cat-theatre --config ~/movies_config.json
→ Option C: Preferred startup method
git clone https://github.com/daocha/plex-cat-theatre
cd plex-cat-theatre
./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:
# if you follow Option A or Option B, then run
plex-cat-theatre --config ~/movies_config.json
# if you follow Option C, then run
python3 movies_server.py --config movies_config.json
Open the UI:
http://localhost:9245
🔑 Change Passcode
# if you follow Option A or Option B, then run
plex-cat-theatre-passcode newpasscode
# if you follow Option C, then run
python3 passcode.py newpasscode
- private folders are hidden unless the device is authorized
- unlock state is tied to a device ID
- approved devices are stored server-side
- The script can rotate the private-mode passcode and clear approvals
🗂️ 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
⚙️ Configuration
The sample config is intentionally sanitized and does not include:
- real file-system paths
- real Plex tokens
- real hashed passcodes
- device-specific values
📍 Important Fields
root |
media roots to scan (support multiple folders) |
thumbs_dir |
directory for thumbnails and preview frames. Default ./cache/thumbnails |
private_folder |
Folder prefixes treated as private. Example Personal. Anything under Personal folder will be locked until you unlock it from the UI. |
private_passcode |
private-mode passcode hash (you should not directly update it with plain text). You you want to update it, refer to Change Passcode section. |
mount_script |
[optional] Command used when playback hits a missing media folder due to folder accidential unmounted. |
transcode |
Enable the catalog-side background transcode worker for source containers such as `.mkv` and `.ts`; this can generate separate transcoded sidecar media files alongside the source library, so it is usually best left `false`, especially when Plex integration is enabled. Default false/td>
|
auto_scan_on_start |
Rescan media on startup. Default false |
on_demand_transcode |
Enable runtime player transcoding for source containers, using hardware encode when available and falling back to software encode when needed. Default true |
on_demand_hls |
Enable built-in HLS playlists for source containers. Default true |
enable_plex_server |
📍 [optional] Enable Plex integration. Default false. Please make sure you have Plex Server installed and configured properly before enabling this.This server supports native subtitles, but if you want to automatically fetch subtitles, it is better to use Plex to fetch these. If you want better on-demand transcoding experience, it is strongly recommended to install Plex server to enable seamless media streaming. Even without Plex server, this server can still work well but pls note the following: → For the media that can be directly played in your device, seeking feature is working perfectly. → For those your device can not directly play: i.e. h.265 with DTS audio (h.265 with acc or mp3 is not affected), .mkv, .ts or .wmv this server is still able to transcode on the fly but seeking feature might not be available.
|
plex.base_url |
Plex server base URL. |
plex.token |
Plex token |
debug_enabled |
Show the built-in debug overlay |
direct_playback |
Object with enabled and audio_whitelist. With enabled=true, this allows you play the media with native player without transcoding (fast). Suggest to use default settings. |
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
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 (i.e. Free subcription), 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
→ 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
This feature is designed for the case some NAS might be configured with auto sleep mode, hence SMB mount might be auto ejected from some Operation system.
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.
📄 Generated Files
These files are runtime-generated and should not be committed:
movies_config.jsonmovies_state.jsonmovies_auth_state.jsonmovies_catalog_index.jsoncache/
🛠️ Troubleshooting
→ 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
→ UI Changes Do Not Appear
- The app currently loads
movies.jsdirectly fromindex.html, so frontend changes take effect without rebuildingmovies.min.js. - 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
📦 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
©️ 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.29.tar.gz.
File metadata
- Download URL: plex_cat_theatre-2026.3.29.tar.gz
- Upload date:
- Size: 264.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fb35798eb90cbeedfaafd7ec00e4758a984aba5c99ad48d78ff217bd720023f6
|
|
| MD5 |
aa3e2760b8e034ea2ac7eb07bea582d8
|
|
| BLAKE2b-256 |
54584e9f05e9345d260012088afddd825401758f4342916db88f75f7bbe6a3f7
|
Provenance
The following attestation bundles were made for plex_cat_theatre-2026.3.29.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.29.tar.gz -
Subject digest:
fb35798eb90cbeedfaafd7ec00e4758a984aba5c99ad48d78ff217bd720023f6 - Sigstore transparency entry: 1193851180
- Sigstore integration time:
-
Permalink:
daocha/plex-cat-theatre@9d91e4c8a8d8313876d7489ce726c85f78ccd351 -
Branch / Tag:
refs/tags/v2026.3.29 - Owner: https://github.com/daocha
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@9d91e4c8a8d8313876d7489ce726c85f78ccd351 -
Trigger Event:
release
-
Statement type:
File details
Details for the file plex_cat_theatre-2026.3.29-py3-none-any.whl.
File metadata
- Download URL: plex_cat_theatre-2026.3.29-py3-none-any.whl
- Upload date:
- Size: 140.7 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 |
79bc5726f3f9fffa9f09ce4bb7276cae7d90ac2a357ecf59a5ccdc376e8d3f72
|
|
| MD5 |
f02d648b6be521ddf1aed26f0fd06f41
|
|
| BLAKE2b-256 |
f030a565ab67becc41c3b12058a98e96238062e4165daf8ad50589c4ebe72058
|
Provenance
The following attestation bundles were made for plex_cat_theatre-2026.3.29-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.29-py3-none-any.whl -
Subject digest:
79bc5726f3f9fffa9f09ce4bb7276cae7d90ac2a357ecf59a5ccdc376e8d3f72 - Sigstore transparency entry: 1193851216
- Sigstore integration time:
-
Permalink:
daocha/plex-cat-theatre@9d91e4c8a8d8313876d7489ce726c85f78ccd351 -
Branch / Tag:
refs/tags/v2026.3.29 - Owner: https://github.com/daocha
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@9d91e4c8a8d8313876d7489ce726c85f78ccd351 -
Trigger Event:
release
-
Statement type: