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.

HTTPS is required. Modern browsers block mixed-content requests — if Plunge is served over HTTPS and your media server is HTTP-only, playback will fail. Either serve your media server over HTTPS (recommended) or run Plunge and your server on the same local HTTP origin.

Requirements

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:

  1. Build locally. Run php scripts/build.php from the project root. The output lands in dist/.
  2. 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.php and the output directory to dist. Cloudflare Pages provides PHP CLI in its build environment.
  3. Configure the custom domain. In the Pages project settings, add your custom domain (e.g. plunge.yourdomain.com). Cloudflare handles the SSL certificate automatically.
  4. 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:

Driver slots

SlotPurpose
driver.dataMedia library — getLibraries, getItems, getMetadata, search, playback reporting
driver.authAuthentication — login, logout, session validation
driver.adminServer management — server info, user list, library rescan
driver.userUser profile — history, favorites, play queue
driver.networkHTTP transport — default headers, fetch wrapper
driver.storagePersistence — secure (credentials) and regular (prefs, cache) storage
driver.lifecyclePlatform lifecycle — visibility change, resume, back-button handling
driver.inputInput model — pointer vs D-pad
driver.mediasessionMedia Session API — OS-level playback controls
driver.notifyToast 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:

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

  1. Pull the new code. git pull origin master from the project root.
  2. Run the build. php scripts/build.php. The dist/ directory is fully regenerated.
  3. Deploy. Copy or sync dist/ to your host. On Cloudflare Pages this happens automatically when the commit lands on master — 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.

Service worker cache. Deployed users may see the old version for up to 24 hours due to service worker caching. A hard-refresh (Ctrl+Shift+R) forces an immediate update. The service worker is versioned by content hash, so it self-updates automatically as long as the user visits the site.

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)
}
MethodPurpose
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>
}
MethodPurpose
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

  1. Create the locale file. Copy src/locales/en.json to src/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.
  2. Set _locale_label. Change the _locale_label value to the native name of the language (e.g. "Français", "Deutsch", "日本語").
  3. Run the build. php scripts/build.php copies the file to dist/locales/{code}.json and regenerates dist/locales/manifest.json to 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:

  1. Build the web assets. Run php scripts/build.php to produce dist/.
  2. Install the webOS CLI. Follow the LG Developer Tools setup guide to install the ares-* CLI tools.
  3. Package. ares-package dist/ -o packaging/webos/. This produces an .ipk file in packaging/webos/.
  4. 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:

Troubleshooting

"Could not reach server" on setup

"Invalid API key" on setup

Media loads but does not play

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.