Self-Hosting Guide
Everything you need to deploy, configure, and extend Plunge on your own infrastructure.
Overview
Plunge is a pure static web application — it has no server-side component of its own. All runtime logic runs in the browser. Deploying it means serving a directory of static HTML, CSS, and JavaScript files from any web host that can serve static content over HTTPS.
The built output lives in dist/ and is fully self-contained. No database,
no runtime, no environment variables. The application connects to your media
server (Jellyfin, Emby, or Plex) at the URL the user provides during setup.
Requirements
- Any static file host that supports HTTPS and can serve
.html,.css, and.jsfiles. - No server-side language (PHP, Node, Python) is needed to run Plunge. PHP is only used to build it — you run the build on your development machine, not on the host.
- To build from source: PHP 8.1+ CLI.
- A Jellyfin, Emby, or Plex server with HTTPS enabled and reachable from the internet (or from the same local network as the browser opening Plunge).
Deploying to Cloudflare Pages
The canonical deployment target for Plunge is Cloudflare Pages. The Forgejo repository
is configured to push to Cloudflare Pages automatically on every commit to
master.
To set up your own Cloudflare Pages deployment from a fork:
-
Build locally.
Run
php scripts/build.phpfrom the project root. The output lands indist/. -
Create a Cloudflare Pages project.
In the Cloudflare dashboard, go to Pages and create a new project. Point it at your
Git repository and set the build command to
php scripts/build.phpand the output directory todist. Cloudflare Pages provides PHP CLI in its build environment. -
Configure the custom domain.
In the Pages project settings, add your custom domain (e.g.
plunge.yourdomain.com). Cloudflare handles the SSL certificate automatically. -
Deploy.
Push to
master. Cloudflare picks up the commit, runs the build, and deploys in about 30 seconds.
Running on a static file server
Any web server capable of serving static files works. Examples:
Nginx
server {
listen 443 ssl;
server_name plunge.yourdomain.com;
root /var/www/plunge/dist;
index index.html;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# All routes fall back to index.html (SPA routing)
location / {
try_files $uri $uri/ /index.html;
}
}
Development server (PHP built-in)
php scripts/build.php
php -S localhost:8888 -t dist/
The built-in PHP server is only for local development. Do not use it in production.
Architecture
Plunge follows a layered architecture with three main zones:
-
Core (
plunge.js,app.js) — the namespace, shared state, event bus, DOM builder utilities, router, and sidebar chrome. No media-server-specific code here. -
Drivers (
js/drivers/) — platform and backend adapters. Each driver slot (data, auth, admin, user, network, storage, lifecycle, input, mediasession, notify) has a base contract and one or more concrete implementations. App code calls the abstract driver interface; the concrete implementation handles the actual HTTP calls, API format, and platform differences. -
Views (
js/views/) — independent view modules for each screen (setup, home, library, item, series, album, player, search, settings, admin, users). Each module exports amount(container, params)/unmount()pair. The router callsmountwith the appropriate container and route params; the view manages its own DOM lifecycle.
Driver slots
| Slot | Purpose |
|---|---|
driver.data | Media library — getLibraries, getItems, getMetadata, search, playback reporting |
driver.auth | Authentication — login, logout, session validation |
driver.admin | Server management — server info, user list, library rescan |
driver.user | User profile — history, favorites, play queue |
driver.network | HTTP transport — default headers, fetch wrapper |
driver.storage | Persistence — secure (credentials) and regular (prefs, cache) storage |
driver.lifecycle | Platform lifecycle — visibility change, resume, back-button handling |
driver.input | Input model — pointer vs D-pad |
driver.mediasession | Media Session API — OS-level playback controls |
driver.notify | Toast notifications and error reporting |
State
All mutable application state lives in Plunge.state. Views and drivers
read from and write to this object via Plunge.setState(patch), which fires
a plunge:statechange event on window with the patch as
event.detail. The router and sidebar listen to this event to react to
authentication and library changes.
Routing
Plunge uses hash-based routing. The router listens to hashchange and maps
patterns like #/library/:id to view mount calls. Navigation uses
Plunge.navigate(hash) or direct window.location.hash assignments.
Build pipeline
The build script is scripts/build.php. It has no dependencies beyond PHP 8.1+.
php scripts/build.php
What it does:
- Renders
src/templates/index.phptodist/index.html - Minifies all CSS from
src/css/intodist/css/ - Copies all JS from
src/js/(and subdirs) intodist/js/preserving structure - Renders all PHP doc templates from
src/docs/intodist/docs/ - Copies fonts, icons, and images verbatim
dist/ is generated — never hand-edit it. All source changes go in src/.
Upgrading
Plunge has no server-side migration process — upgrading means pulling new source code and re-running the build. User preferences are stored in the browser, so upgrading the deployed files does not erase anyone's settings.
Manual upgrade
-
Pull the new code.
git pull origin masterfrom the project root. -
Run the build.
php scripts/build.php. Thedist/directory is fully regenerated. -
Deploy.
Copy or sync
dist/to your host. On Cloudflare Pages this happens automatically when the commit lands onmaster— no manual step needed.
Breaking changes to the prefs schema
User preferences are persisted as a flat JSON object under the key pm-prefs in
localStorage. Plunge reads prefs defensively — missing or unknown keys are
treated as unset and default to their runtime defaults, so old prefs objects continue to work
when new keys are added.
On rare occasions a key may be renamed or its format may change. When that happens, the release notes for that version will describe the affected key and the default that takes effect. Users who care about a specific setting (e.g. their preferred exploration scale) will need to re-set it in Settings after upgrading. There is no automatic migration — the stale key is ignored and the default kicks in.
Auto-deploy on master push
If you deploy via Cloudflare Pages, any push to master triggers a build and
deploy automatically. The pipeline runs php scripts/build.php in the Cloudflare
build environment (PHP CLI is available) and publishes dist/. Typical deploy
time is under a minute. Check the Cloudflare Pages dashboard for build logs if a deploy
fails.
Driver interface
To add a new backend (e.g. a custom Jellyfin fork, a different streaming service), implement
the relevant driver contract and register it via Plunge.setBackend().
The minimum contract for a DataDriver is:
class MyDataDriver {
connect(bootstrap) // → Promise<boolean>
getLibraries() // → Promise<Library[]>
getItems(libraryId, opts) // → Promise<{items, total}>
getMetadata(itemId) // → Promise<Item|null>
search(query, opts) // → Promise<Item[]>
getPlaybackUrl(itemId, opts) // → Promise<string>
reportPlaybackStart(itemId, state) // → Promise<void>
reportPlaybackProgress(itemId, state) // → Promise<void>
reportPlaybackStop(itemId, state) // → Promise<void>
markPlayed(itemId) // → Promise<void>
setFavorite(itemId, isFavorite) // → Promise<void>
getPrefs() // → Promise<Prefs|null>
setPrefs(prefs) // → Promise<void>
clearCache() // → Promise<void>
}
All methods return Promises. Methods that have no meaningful implementation for a given
backend should return Promise.resolve() rather than throwing.
See src/js/drivers/data/base.js for the full base class with JSDoc for every
method signature.
AuthDriver contract
Handles credential validation and session management. Register via Plunge.setBackend()
alongside the DataDriver.
class MyAuthDriver {
login(credentials) // → Promise<{ token: string, userId: string }>
logout() // → Promise<void>
isSessionValid(bootstrap) // → Promise<boolean>
refreshToken(bootstrap) // → Promise<string> (reject if not supported)
}
| Method | Purpose |
|---|---|
login(credentials) | Validate the credentials object (URL + token or username/password) against the server. Resolve with a session token and userId on success; reject on failure. |
logout() | Invalidate the current session on the server side. Called when the user signs out. |
isSessionValid(bootstrap) | Check whether a stored session token is still valid. Called on page load before showing the home screen. Resolve false to send the user to setup. |
refreshToken(bootstrap) | Exchange an expiring token for a new one. Reject with an error if the backend does not support token refresh — Plunge will fall back to re-authentication. |
AdminDriver contract
Provides server management capabilities. The admin and users screens call these methods.
All are optional — return Promise.resolve() for any method your backend does
not support. See src/js/drivers/admin/base.js for full JSDoc.
class MyAdminDriver {
getServerInfo() // → Promise<ServerInfo|null>
getUsers() // → Promise<User[]>
createUser(params) // → Promise<{ id: string }>
updateUser(userId, patch) // → Promise<void>
deleteUser(userId) // → Promise<void>
setUserAccess(userId, opts) // → Promise<void>
getApiKeys() // → Promise<ApiKey[]>
createApiKey(appName) // → Promise<void>
revokeApiKey(token) // → Promise<void>
rescanLibrary(libraryId) // → Promise<void>
getActiveSessions() // → Promise<Session[]>
stopSession(sessionId) // → Promise<void>
refreshItemMetadata(itemId) // → Promise<void>
analyzeItem(itemId) // → Promise<void>
unmatchItem(itemId) // → Promise<void>
fixMatchSearch(itemId, query, year) // → Promise<Match[]>
fixMatchApply(itemId, guid, matchTitle) // → Promise<void>
deleteItem(itemId) // → Promise<void>
updateItemMetadata(itemId, patch) // → Promise<void>
setItemImage(itemId, imageType, imageUrl)// → Promise<void>
updateItemPrefs(itemId, prefs) // → Promise<void>
renderAdminPanel(container) // → void
renderUsersPanel(container) // → void
}
UserDriver contract
Handles per-user state that lives on the server: profile, preferences, watch history, and
favorites. See src/js/drivers/user/base.js for full JSDoc.
class MyUserDriver {
getProfile() // → Promise<UserProfile|null>
getPreferences() // → Promise<Prefs|null>
setPreferences(prefs) // → Promise<void>
getHistory(opts) // → Promise<Item[]>
getFavorites(opts) // → Promise<Item[]>
getQueue() // → Promise<{ entries: Item[], position: number }>
saveQueue(queue) // → Promise<void>
}
| Method | Purpose |
|---|---|
getProfile() | Fetch display name, avatar URL, and other profile fields for the current user. |
getPreferences() | Load server-stored preferences (locale, subtitles, etc.). Merged with local prefs at boot. |
setPreferences(prefs) | Persist preferences back to the server so they roam across devices. |
getHistory(opts) | Fetch recently-played items in reverse-chronological order. |
getFavorites(opts) | Fetch items the user has marked as favorites on the server. |
getQueue() | Load a persistent play queue from the server. Resolve with an empty queue if unsupported. |
saveQueue(queue) | Persist the current play queue to the server for cross-device continuity. |
Internationalisation
Plunge ships with English (en) and Spanish (es) locale files.
Adding a language requires only a JSON file — no code changes are needed.
Locale file format
Each locale is a flat JSON file at src/locales/{code}.json. Keys are
dot-separated identifiers; values are the translated strings. Variable placeholders
use {var} syntax:
{
"_locale_label": "Español",
"nav.home": "Inicio",
"player.position": "{position} de {total}"
}
The _locale_label key is required — it provides the human-readable name
shown in the Settings language picker. The build script reads this key when generating
manifest.json. All other keys must match the keys in en.json;
any key absent from a locale file falls back to the English string at runtime.
Adding a new language
-
Create the locale file.
Copy
src/locales/en.jsontosrc/locales/{code}.json, where{code}is the BCP 47 language code (e.g.fr,de,ja). Translate the values; leave untranslated keys present so the file compiles correctly — they fall back to English at runtime. -
Set
_locale_label. Change the_locale_labelvalue to the native name of the language (e.g."Français","Deutsch","日本語"). -
Run the build.
php scripts/build.phpcopies the file todist/locales/{code}.jsonand regeneratesdist/locales/manifest.jsonto include the new entry. The new language appears in Settings → Language automatically.
How manifest.json is generated
The build script scans every *.json file in src/locales/,
skipping any file named manifest.json. For each file it reads
_locale_label and emits a { code, label } entry. English
is sorted first; all other locales are sorted alphabetically by label. The result is
written to dist/locales/manifest.json.
You do not need to maintain manifest.json by hand — it is always
regenerated from the locale files on every build.
TV packaging
webOS
The webOS package manifest is at packaging/webos/appinfo.json. To build an
IPK for sideloading:
-
Build the web assets.
Run
php scripts/build.phpto producedist/. -
Install the webOS CLI.
Follow the LG Developer Tools setup guide to install the
ares-*CLI tools. -
Package.
ares-package dist/ -o packaging/webos/. This produces an.ipkfile inpackaging/webos/. -
Install.
Connect your TV to developer mode and run
ares-install io.step41.plunge_*.ipk.
Android TV
The Android TV version uses a PWA manifest (packaging/androidtv/manifest.json)
that configures full-screen landscape display. Distribution options:
- PWA install via browser — open the Plunge URL in Chromium-based Android TV browsers and use "Add to Home Screen."
-
Trusted Web Activity (TWA) — wrap the Plunge URL in a TWA shell app using
Android Bubblewrap. The manifest at
packaging/androidtv/manifest.jsonprovides the required PWA metadata. See the Bubblewrap documentation for build instructions.
Troubleshooting
"Could not reach server" on setup
- Check that the server URL includes the correct port (Jellyfin defaults to 8096, Plex to 32400).
- Confirm the server is running and reachable from the device you are using — try opening the server URL directly in another browser tab.
- If Plunge is served over HTTPS and your server is HTTP-only, the browser will block the mixed-content request. Either enable HTTPS on your server or access Plunge over HTTP.
"Invalid API key" on setup
- For Jellyfin/Emby: make sure you copied the API key exactly — no leading or trailing spaces.
- For Plex: ensure the token belongs to the account that owns the Plex Media Server you are connecting to. Guest tokens do not have the same access level.
Media loads but does not play
- Check browser console for CORS errors — your server may need to allow the Plunge origin.
- In Jellyfin and Emby, confirm "Allow remote connections" is enabled in the server settings.
- Try lowering the max bitrate in Settings. If the server cannot transcode fast enough, playback may stall immediately.
Service worker not updating after a deploy
The service worker caches the app shell aggressively. After a deploy, users may need to hard-refresh (Ctrl+Shift+R or Cmd+Shift+R) or clear site data to pick up the new version. The service worker is versioned by a hash; when the hash changes on deploy, browsers will download the new worker and update automatically within 24 hours.