diff --git a/README.md b/README.md
index 2e1222b..7cd1b51 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,10 @@
A self-hosted web console for scanning, connecting to, and controlling Bluetooth LE sex toys over the
[Buttplug](https://buttplug.io) protocol - the same open-source protocol behind Intiface Central and most
-other legitimate Bluetooth toy-control apps. Record a live control session, save it as a named recording,
-and replay it later against a (possibly different) set of connected devices. Track usage in a stats
-dashboard. All gated behind a single shared secret, all data kept in a local SQLite file.
+other legitimate Bluetooth toy-control apps. Every live control session is recorded automatically, and any
+completed session can be replayed later directly, against a (possibly different) set of connected devices -
+no separate "save as recording" step. Track usage in a stats dashboard. All gated behind a single shared
+secret, all data kept in a local SQLite file.
## Architecture, in words
@@ -16,14 +17,14 @@ Bluetooth hardware at all. So:
as an *embedded* connector - a full Buttplug server compiled to WebAssembly, talking to devices directly
over Web Bluetooth. There's no separate Intiface Engine or Buttplug Server process to run.
- **The Next.js server is never in the real-time device-control loop.** It only handles the login gate,
- persists recordings and session telemetry to SQLite, serves the stats aggregation endpoints, and serves
- recordings back for replay - all over plain HTTPS/JSON, batched every few seconds, not per slider tick.
+ persists session telemetry to SQLite, serves the stats aggregation endpoints, and serves a session's
+ events back for replay - all over plain HTTPS/JSON, batched every few seconds, not per slider tick.
```
Browser Server (Next.js)
┌─────────────────────────┐ ┌───────────────────────┐
│ buttplug-wasm (embedded)│ │ auth / API routes │
- │ ↕ Web Bluetooth │ HTTPS │ recordings + sessions │
+ │ ↕ Web Bluetooth │ HTTPS │ sessions │
│ BLE devices │ ───────► │ stats aggregation │
│ live control / replay │ (batch) │ SQLite (better-sqlite3)│
└─────────────────────────┘ └───────────────────────┘
@@ -42,12 +43,12 @@ Bluetooth hardware at all. So:
- Scan for and connect to multiple Bluetooth LE devices at once
- Live per-actuator control (vibrate / rotate / linear) with a responsive slider UI
-- Start/end sessions; save a completed session as a named, replayable recording
-- Replay a recording against a different set of connected devices, with a device-remap step (BLE exposes no
- stable device id across sessions, so recorded devices are matched to live ones by name, with manual
- override when needed)
-- Stats dashboard: session summaries, per-session intensity timelines, per-device usage, recording-library
- stats (play counts, average length)
+- Start/end sessions; name one so it's easy to find later
+- Replay any completed session directly against a different set of connected devices, with a device-remap
+ step (BLE exposes no stable device id across sessions, so the session's devices are matched to live ones
+ by name, with manual override when needed) - replaying doesn't create a new session or duplicate events,
+ it just bumps the source session's play count
+- Stats dashboard: session summaries, per-session intensity timelines, per-device usage, replay counts
- Single shared-secret login, no user accounts
- SQLite storage, Docker Compose deployment, Gitea Actions CI
@@ -87,9 +88,15 @@ Compose-only (read by `docker-compose.yml` for `${...}` substitution, not by the
## Database
Schema lives in `lib/db/schema.ts`, migrations in `drizzle/` (generate new ones with `pnpm db:generate`
-after a schema change, and commit the generated SQL). A recording is a thin pointer over an already-captured
-session's events, not a separate capture pipeline - every live command is always persisted for stats,
-regardless of whether the session is later saved as a named recording.
+after a schema change, and commit the generated SQL). Every live command is always persisted to
+`session_events` for stats, regardless of whether the session is ever replayed - replaying reads those same
+events back directly, it doesn't create a new session or duplicate them, it just bumps the source session's
+`playCount`/`lastPlayedAt`.
+
+Renaming a table or column makes `drizzle-kit generate` prompt interactively ("is this a rename?"), which
+needs a real TTY and won't work non-interactively (e.g. from an agent or CI). In that case, hand-author the
+migration SQL and its `drizzle/meta/*_snapshot.json` instead, then confirm `pnpm db:generate` reports no
+further changes against the updated schema.
## Docker deployment
@@ -116,18 +123,18 @@ Docker image to this repo's path on the `dev.pivoine.art` Gitea registry, authen
1. Log in with the shared secret.
2. On **Control**, scan for devices and connect. Move the sliders to control actuators live.
-3. Click **Start session** before you begin if you want this session tracked in stats or saved as a
- recording; **End session** when done, and optionally name it to save it as a recording.
-4. On **Recordings**, click replay on a saved recording, connect the devices you want to use, match them to
- the recording's original device slots, and play back.
-5. **Sessions** and **Stats** show history and aggregated usage.
+3. Click **Start session** before you begin; **End session** when done, and optionally name it so it's easy
+ to find later.
+4. On **Sessions**, click replay on any completed session, connect the devices you want to use, match them
+ to the session's original device slots, and play back.
+5. **Stats** shows aggregated usage across sessions and devices.
## Security & privacy notes
- This is a single shared-secret gate, not multi-user authentication - anyone with the secret has full
access. Treat it like a shared house key; restricting network access (VPN, IP allowlist, Traefik
middleware) in addition to the app secret is recommended for anything beyond trusted personal use.
-- All data (recordings, session history, device names) stays in your own SQLite file. Nothing is sent
+- All data (session history, device names) stays in your own SQLite file. Nothing is sent
anywhere except directly between your browser and the devices it connects to, and between your browser
and this server.
- There's no login rate-limiting in this version - acceptable behind a private deployment, but worth
diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx
index 8a7bb13..da6f174 100644
--- a/app/(app)/page.tsx
+++ b/app/(app)/page.tsx
@@ -2,8 +2,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BrandMark } from "@/components/layout/BrandMark";
-import { listRecordings } from "@/lib/db/queries/recordings";
-import { listPlaySessions } from "@/lib/db/queries/play-sessions";
+import { listSessions } from "@/lib/db/queries/sessions";
import { getSessionsSummary } from "@/lib/db/queries/stats";
// Always reflects live DB state for an authenticated, single-tenant app -
@@ -11,13 +10,8 @@ import { getSessionsSummary } from "@/lib/db/queries/stats";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
- const [recordings, sessions, summary] = await Promise.all([
- listRecordings(),
- listPlaySessions(),
- getSessionsSummary(),
- ]);
+ const [sessions, summary] = await Promise.all([listSessions(), getSessionsSummary()]);
- const recentRecordings = recordings.slice(0, 5);
const recentSessions = [...sessions].reverse().slice(0, 5);
return (
@@ -27,7 +21,7 @@ export default async function DashboardPage() {
- Scan for nearby devices, take control, and record sessions to replay later - all running + Scan for nearby devices, take control, and replay completed sessions later - all running directly from your browser over Web Bluetooth.