Repo layout: the TV dashboard app lives in
app/, the RAMTECH device-manager web UI inadmin/, the bootable OS image build inos/, and the one-click USB writer inimager/— see RAMTECH OS live USB at the bottom.start.bat/start.shstill run the app from the repo root as before.
A full-screen "listening party" dashboard for a TV. It shows:
- Now playing — big album art, track, artist, live progress bar
- Up next — the live queue, each song tagged with who added it
- Jam QR code — auto-generated from your live Spotify Jam; guests scan it to join and add songs
- Clock, date, and weather — top bar, updates on its own
Point a browser on your TV at it, full-screen it, done.
Just double-click app/preview.html. It opens in any browser and shows the
exact design with fake demo data (needs internet for the demo album art + QR).
No Node, no Spotify account required. Use this to judge the look.
- Node.js 18+
- A Spotify Premium account (playback/queue API needs Premium)
The app lives in app/, so that is where npm runs (there is no package.json at
the repo root — start.bat / start.sh just cd into it for you):
cd app
npm install
npm startOpen http://localhost:3000 on the TV (or http://<your-computer-ip>:3000
from any device on the network) and full-screen it.
The first time it runs, the TV shows a big "Scan to set up" QR code.
While it boots, the server opens a temporary public setup link through a
localtunnel (https://<random>.loca.lt on port 443 — pure HTTP/HTTPS and
WebSockets, no SSH protocol required); the QR points at it. Scan it with your phone
and follow the steps on the page that opens:
- Spotify app credentials — create an app in the Spotify Developer Dashboard, add the Redirect URI(s) shown on the setup page (copy buttons provided — the localtunnel one is first), and paste the app's Client ID. No client secret needed (sign-in uses PKCE).
- Sign in with Spotify — tap the button, approve on Spotify, and you
bounce straight back. The server exchanges and stores the tokens itself
(
.data/auth.json) and keeps itself signed in from then on — no curl, no copying tokens. - The TV flips to the dashboard automatically.
You can revisit the setup page any time at http://127.0.0.1:3000/setup on
the machine itself (or https://<your-ip>:3443/setup on the LAN) to
reconnect, disconnect, or add the Jam cookie.
Notes on the tunnel: the localtunnel URL is random on every server start, so the redirect URI you register is the one shown at that moment — that's fine, setup is a one-time thing. Set
TUNNEL=falsein.envto disable tunnels entirely.The tunnel stays up for the whole session — it is not just a setup device. It is the address the Jam QR points at, which is what lets a guest scan from mobile data instead of having to be on your Wi-Fi. So treat the
loca.ltURL as public, and note what is and is not reachable through it:
Over the public tunnel Only from the TV / your LAN the dashboard view and /api/statethe Spotify access token ( /api/token)/setup, read-onlyplay / pause / skip / volume the /jJam redirectthe remote sign-in screen-share and its input Changing anything on
/setup— and viewing or driving the remote sign-in — needs the 6-digit code shown only on the TV, so you have to be able to see the screen. Five wrong codes locks that address out for five minutes.Without the tunnel (offline, or
TUNNEL=false): the QR falls back to the server's self-signed HTTPS listener (https://<your-ip>:3443/setup). Spotify requires HTTPS redirect URIs, hence the self-signed cert — your phone shows a one-time "connection not private" warning, tap Advanced → Continue. If the dashboard rejects the LANhttps://…URI, sign in once from a browser on the computer running the server athttp://127.0.0.1:3000/setup— loopback is always accepted.
Spotify's Jam feature has no official API, so the app reads it through the same internal endpoints the web player uses. That needs your logged-in web session cookie — this is the one thing that can't be automated:
- In a desktop browser, log into https://open.spotify.com.
- Open DevTools → Application (Chrome) or Storage (Firefox) → Cookies →
https://open.spotify.com. - Paste the value of the
sp_dccookie into step 3 of the setup page (or intoSPOTIFY_SP_DCin.env).
That cookie is long-lived (months). Treat it like a password — anyone with it can act as your Spotify login. Skip it and everything except the Jam QR / contributor names still works.
How the QR works: with
sp_dcset, the server starts the Jam by itself as soon as music is playing on any of your devices (it calls the samecurrent_or_newendpoint the web player uses) and renders the join link as a QR. Guests scan → land in your Jam → their additions show up in Up next with their name. Prefer starting Jams manually from the phone app? SetJAM_AUTO_CREATE=false— the dashboard then just detects the session you start.
With WEBPLAYER=true (the default), the dashboard loads Spotify's Web
Playback SDK and the TV shows up as a Spotify Connect device named TV Jam
(change with WEBPLAYER_NAME). This means the TV can play music on its own —
so the Jam has an active device and comes up with no phone or computer
needed.
- Requires Spotify Premium (an SDK requirement).
- If you signed in before this feature existed, reconnect once at
/setup— sign-in now also asks for thestreamingpermission. The TV will prompt "Reconnect Spotify to enable playback" if the scope is missing. - First play needs one tap (browsers block autoplay): the TV shows a
"▶ Play on this TV" button; tap it and playback starts on the TV and the
Jam auto-creates. To skip even that tap in kiosk mode, launch Chromium with
--autoplay-policy=no-user-gesture-required(the bundledstart.batalready does this) and it starts on its own. - Needs a browser with EME/Widevine (Chrome, Edge, Firefox, Chromium kiosk — most smart-TV built-in browsers do not qualify; use a Pi/mini-PC).
- Set
WEBPLAYER=falseto turn the TV back into a display-only dashboard.
The weather place and an optional countdown (e.g. "⏳ 42 days until
Kickoff", shown under the clock; flips to a live timer in the last 48 h) are
set from the /setup page → Dashboard preferences — no restart needed.
.env values act as defaults:
WEATHER_CITY=San Francisco
TEMP_UNIT=fahrenheit # or celsius
CLOCK_24H=false # true for 24-hour time
COUNTDOWN_DATE=2026-12-31 # optional; /setup value wins
COUNTDOWN_LABEL=New Year
Weather uses Open-Meteo — free, no API key.
- Smart TV / browser: just navigate to
http://<your-computer-ip>:3000and full-screen. - Raspberry Pi / mini PC (recommended, rock solid): run the server, then launch Chromium in kiosk mode:
chromium-browser --kiosk --incognito http://localhost:3000
- Chromecast/AirPlay: cast the browser tab.
Keep the machine running the server on the same network as the TV.
The internal Jam endpoints are undocumented and Spotify changes them occasionally. If the app can't resolve a live Jam, it will:
- Still show now-playing + queue perfectly (that's the stable Web API).
- Render
JAM_URL_FALLBACKas the QR if you set one in.env— paste any Jam invite link there as a manual backstop. - Otherwise show "Start a Jam on your phone to activate."
Per-song "added by" names come from the Jam session data; songs added outside a Jam (or when the session doesn't expose the adder) simply show without a name.
- The
sp_dc/ social-connect approach is unofficial. It works today but isn't guaranteed by Spotify and may break if they change their internal API — this is the only way to get Jam data, since the public API doesn't expose it. - Everything degrades gracefully: if the Jam or weather calls fail, the rest keeps working.
- Requires Spotify Premium for the playback/queue endpoints.
- If the stored sign-in is ever revoked, the TV automatically falls back to the "Scan to set up" screen.
.envvalues still work as overrides/seeds (e.g. a hand-obtainedSPOTIFY_REFRESH_TOKEN); anything saved via/setupwins.
Turn any Debian/Ubuntu box into a plug-in-and-go dashboard that boots straight into the TV view. For a Promethean panel you almost certainly want the live USB instead (bottom of this file) — it installs nothing.
git clone <this repo> ~/spotify-tv-jam
cd ~/spotify-tv-jam
bash deploy/install.sh # installs Node + Chromium, sets up auto-start
sudo rebootdeploy/install.sh (Debian/Ubuntu/Armbian, ARM64-flavoured):
- installs Node 20 + Chromium if missing,
- runs the server as a systemd service (
spotify-tv-jam, auto-restart on crash/boot), - adds a desktop auto-start that opens Chromium full-screen on the dashboard (
deploy/kiosk.sh).
Logs: journalctl -u spotify-tv-jam -f. It runs entirely on the box — the
localtunnel is only the public address the Jam QR points at.
Branding: drop your logo at app/public/ramtech-logo.png.
Locked-down setup: because the tunnel makes /setup public, changing anything
requires a 6-digit code shown only on the TV (loopback-only). View the page from
anywhere; you can only edit it if you can see the screen. Requests from the Pi itself
skip the code. Five wrong codes lock that address out for five minutes. The same code
gates the remote sign-in screen-share, and playback control plus the Spotify token are
never served over the tunnel at all — only to the TV and the local network.
Audio: ARM Chromium can't do Spotify's DRM web player, so install.sh sets the
box up to play through librespot (installed as raspotify) — a headless Spotify
Connect device named RAMTECH TV. One-time: on your phone's Spotify app (same
account, Premium), open the devices menu and tap RAMTECH TV once to authorize
it; it then reconnects on its own forever. On x86 this sidecar isn't needed — see the
live USB section, which ships Google Chrome and plays through the Web Playback SDK.
Playback rule: music plays only while someone else is in the Jam, and
joining is what starts it: Spotify gives remote guests no way to start the
host's playback (a guest pressing play on their phone starts local playback and
drops them out of the Jam), so the host must be playing for guest phones to mirror
the Jam and queue into it. Boot is silent (the seed playlist is loaded but paused —
just enough for the QR to appear); the first guest joining starts playback once;
a deliberate pause mid-Jam is respected, not fought; the last guest leaving pauses
it again (JAM_PLAY_ON_GUEST=true controls all of this).
app/server.js Express server + /api/state + setup/OAuth routes + server-side QR
app/lib/auth.js Sign-in flow (PKCE), token store (.data/auth.json), self-signed TLS
app/lib/tunnel.js localtunnel HTTPS reverse tunnel (public address on port 443 for setup + Jam QR)
app/lib/spotify.js Web API (now playing, queue) + Jam session + join-URL builder
app/lib/webtoken.js Mints the Jam web token via the logged-in browser (no TOTP forgery)
app/lib/remote.js Phone-driven remote Spotify sign-in (CDP screen-share) + browser finder
app/lib/weather.js Open-Meteo geocode + current conditions
app/lib/demo.js Fake data for DEMO=true
app/public/ The TV UI (index.html, style.css, app.js), player.js
(Web Playback SDK), and setup.html (OTP-gated phone sign-in)
app/deploy/kiosk.sh Kiosk browser launcher (used by the OS image + install.sh)
app/.data/ Generated at runtime: tokens, TLS cert (gitignored)
admin/ RAMTECH device manager web UI (port 8080) — see below
os/ RAMTECH OS live-USB image build (Debian live-build) — see below
os/overlay/ What gets baked into the image: systemd units, OTA scripts,
kiosk session, branding, seeded .env
os/make-package.sh Builds the update package "Install from a file" takes (the
same artifact CI publishes)
imager/ RAMTECH Imager — the Electron GUI that writes the image to USB
deploy/install.sh Manual installer for an existing Debian/Armbian board
.github/workflows/ release.yml: tag v* → OTA tarball on GitHub Releases
A bootable USB stick that turns the OPS module in a Promethean panel — or any x86 mini PC — into the dashboard. Nothing is installed on the panel: pull the stick out and the machine boots back into whatever it ran before.
The whole process is two steps.
Open RAMTECH Imager, pick a USB stick, press Write.
It downloads the latest image from this repo's GitHub Releases (cached, so the second stick is instant), checks its SHA-256, erases the stick, writes it and reads it back to verify. Nothing to configure and no partitioning to do — the persistence partition is already inside the image.
cd imager
npm install
npm start # or `npm run dist` to build an installerWindows needs Administrator (the app asks and restarts itself); macOS and Linux
prompt for a password when the write starts. If you'd rather not run it,
balenaEtcher and Rufus both open the .img.gz directly and do the same
job — the imager just removes the "which of these 14 options do I pick" problem.
- Plug the stick into a USB port on the OPS module — the PC in the bay at the back of the panel, not a panel-front port (those are wired to Android).
- Power on and press the boot-menu key: F7 on most Promethean OPS modules, sometimes F12 or Esc. Pick the USB stick.
- To make it permanent, put the stick first in the boot order in BIOS setup (Del or F2).
The image is Secure Boot signed (Debian's shim), so it boots without turning Secure Boot off. It boots UEFI; the BIOS/CSM path is best-effort.
The boot menu shows for two seconds and then starts on its own — a panel has no keyboard, and live-build's stock templates wait at that menu forever. Press a key during those two seconds to reach the fail-safe entry.
First boot lands on the dashboard's "Scan to set up" QR — sign in to Spotify from a phone exactly as described at the top of this file. That sign-in is written to the stick's persistence partition and is still there after a reboot.
- Debian trixie live system, branded RAMTECH OS, built with
live-build - Boots keyboard-free: autologin → X11 → Google Chrome kiosk on
localhost:3000 spotify-tv-jam+ramtech-adminas systemd services- The panel plays the music itself. Google Chrome ships Widevine, so the Spotify Web Playback SDK works and the panel appears as the RAMTECH TV Connect device — no librespot sidecar, unlike the ARM build
- A 4 GB ext4 persistence partition mounted
/ union: Spotify tokens, admin settings, Wi-Fi and OTA-installed app versions all survive reboots - App under
/opt/ramtech/releases/<ver>with acurrentsymlink; state in/opt/ramtech/data
Console login (plug in a keyboard, Ctrl+Alt+F2): ramtech / ramtech. Root is
locked. Anyone holding the stick owns it — treat it like a key, not a password.
http://ramtech.local:8080 — default password ramtech, and the change is
genuinely forced: while the password is still the default, every other route
(including the console terminal and the VNC screen) is refused, so the only
thing a fresh device will let you do is set a real password. Changing it also
invalidates any session signed under the old one, so you sign in again after.
Status/temps, service control, journals, Wi-Fi,
hostname, apt upgrades, reboot — and Software update: checks this repo's
GitHub Releases, installs atomically, health-checks the app and rolls back
automatically if the new version doesn't come up. Optional daily auto-update
timer. Updates land on the persistence partition, so they stick.
A panel with no internet — or one that has to run a build nobody released — uses
Install from a file in that same panel: pick a ramtech-app-vX.Y.Z.tar.gz
off your laptop and it goes through the identical pipeline. See
Updating a device without internet.
bash os/build.sh # ~30 min the first time, ~15 after
bash os/build.sh --clean # throw away the package cache too
bash os/build.sh --smoke-test # then boot the result in QEMU and screenshot it
PERSIST_MB=8192 bash os/build.sh # a bigger persistence partitionThe first run downloads roughly 1.5 GB of Debian packages; later runs reuse them
from a Docker volume, so most of the time goes on squashfs and gzip.
The only requirement is Docker — the build runs in the Debian container from
os/Dockerfile, so the command is the same on Windows (Git
Bash), macOS and Linux. Output lands in os/out/:
| file | what it is |
|---|---|
ramtech-os-<ver>-x86_64.img.gz |
the USB image, gzip so the imager can stream it with no dependencies |
ramtech-os-<ver>-x86_64.img.gz.sha256 |
what the imager checks the download against |
ramtech-os-<ver>-x86_64.iso |
the same system as a plain ISO, for VMs — no persistence |
build.log |
the full live-build log |
The build bakes in the latest GitHub release of the app (repo set in
os/overlay/seed/repo.txt); drop a
ramtech-app-<ver>.tar.gz into os/overlay/seed/ to build fully offline.
How the image is put together — live-build produces a hybrid ISO, and then
os/finish-image.sh appends a labelled ext4 ramtech-data
partition to it and writes persistence.conf inside. Baking the partition in
rather than creating it on the stick is what lets the imager be one click on
Windows, which cannot format ext4. The finished layout:
1 1.2 GB bootable the ISO itself (squashfs, kernel, initrd)
2 3.3 MB EFI the ESP, embedded in the ISO — Debian's signed shim + GRUB
3 4.0 GB Linux ext4 "ramtech-data", persistence.conf = "/ union"
git tag v1.2.3 && git push origin v1.2.3CI builds ramtech-app-v1.2.3.tar.gz (app + admin + updater, node_modules
bundled), publishes a GitHub Release, then builds the OS image against that
release and attaches it too. Existing sticks pick the app up via Check in
the device manager or the daily timer; the imager offers the new image to
anyone writing a fresh stick.
Every panel is not on a network that can reach GitHub, and not every build is
worth a release. Software update → Install from a file in the device manager
takes the package straight from the browser instead, and runs it through the
same machinery as an OTA update: checked, unpacked into
/opt/ramtech/releases/, current flipped, app restarted and health-checked —
rolled back automatically if the new version doesn't come up. It lands on
the persistence partition, so it survives reboots like any other update.
Get a package on a machine that does have internet, three ways:
- RAMTECH Imager → Save update package — the same app you write sticks with.
It pulls
ramtech-app-vX.Y.Z.tar.gzfrom the release it would write, checks its SHA-256, and saves it where you point it. - Download it from this repo's Releases by hand.
- Build the exact same artifact from a checkout:
npm ci --omit=dev --prefix app
npm ci --omit=dev --prefix admin
bash os/make-package.sh v1.2.3 # → ramtech-app-v1.2.3.tar.gz (+ .sha256)Then open http://ramtech.local:8080, pick the file, and press Install
package. The upload has a progress bar; the install reports itself on the same
bar an OTA update uses.
Two things work differently from an OTA update, both on purpose:
- It doesn't have to be newer. Reinstalling the running version, or installing an older one to back out of a bad build, is allowed — a local install is a deliberate act, not a check for something better.
- It never overwrites a release directory that is in use. A package whose
version is already installed lands beside it as
vX.Y.Z+local-<timestamp>, so the running release stays intact and Roll back still returns to it.
The version has to be a plain vX.Y.Z (the device turns it into a directory
name as root, so anything else is refused), and it is read from the VERSION
file inside the package — which is also how a truncated or corrupt upload is
caught before anything on the device is touched.
This updates the RAMTECH software stack — app, device manager and updater — which is what a release is. The Debian base underneath comes from the USB image:
apt upgradein the device manager patches it, and a new base image means writing a new stick with the imager.