1 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 401b9b5033 Remove recordings feature, replay sessions directly, bump to 0.6.0
CI / Build and push image (push) Successful in 1m41s
CI / Static checks (push) Successful in 2m12s
Recordings were just a thin named pointer over an already-captured
session's events, so the whole separate feature (recordings table, API
routes, pages, UI) is gone: any completed session can now be named and
replayed directly. Replaying no longer creates a session or duplicates
events of its own - it just bumps the source session's playCount/lastPlayedAt.

Also renames play_sessions/playSession(s) to sessions/session(s) throughout
the schema, queries, API routes, and UI for consistency, and updates the
README to match the new flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:38:51 +02:00
46 changed files with 1710 additions and 1018 deletions
+28 -21
View File
@@ -2,9 +2,10 @@
A self-hosted web console for scanning, connecting to, and controlling Bluetooth LE sex toys over the 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 [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, other legitimate Bluetooth toy-control apps. Every live control session is recorded automatically, and any
and replay it later against a (possibly different) set of connected devices. Track usage in a stats completed session can be replayed later directly, against a (possibly different) set of connected devices -
dashboard. All gated behind a single shared secret, all data kept in a local SQLite file. 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 ## 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 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. 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, - **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 persists session telemetry to SQLite, serves the stats aggregation endpoints, and serves a session's
recordings back for replay - all over plain HTTPS/JSON, batched every few seconds, not per slider tick. events back for replay - all over plain HTTPS/JSON, batched every few seconds, not per slider tick.
``` ```
Browser Server (Next.js) Browser Server (Next.js)
┌─────────────────────────┐ ┌───────────────────────┐ ┌─────────────────────────┐ ┌───────────────────────┐
│ buttplug-wasm (embedded)│ │ auth / API routes │ │ buttplug-wasm (embedded)│ │ auth / API routes │
│ ↕ Web Bluetooth │ HTTPS │ recordings + sessions │ ↕ Web Bluetooth │ HTTPS │ sessions
│ BLE devices │ ───────► │ stats aggregation │ │ BLE devices │ ───────► │ stats aggregation │
│ live control / replay │ (batch) │ SQLite (better-sqlite3)│ │ 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 - Scan for and connect to multiple Bluetooth LE devices at once
- Live per-actuator control (vibrate / rotate / linear) with a responsive slider UI - Live per-actuator control (vibrate / rotate / linear) with a responsive slider UI
- Start/end sessions; save a completed session as a named, replayable recording - Start/end sessions; name one so it's easy to find later
- Replay a recording against a different set of connected devices, with a device-remap step (BLE exposes no - Replay any completed session directly against a different set of connected devices, with a device-remap
stable device id across sessions, so recorded devices are matched to live ones by name, with manual step (BLE exposes no stable device id across sessions, so the session's devices are matched to live ones
override when needed) by name, with manual override when needed) - replaying doesn't create a new session or duplicate events,
- Stats dashboard: session summaries, per-session intensity timelines, per-device usage, recording-library it just bumps the source session's play count
stats (play counts, average length) - Stats dashboard: session summaries, per-session intensity timelines, per-device usage, replay counts
- Single shared-secret login, no user accounts - Single shared-secret login, no user accounts
- SQLite storage, Docker Compose deployment, Gitea Actions CI - 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 ## Database
Schema lives in `lib/db/schema.ts`, migrations in `drizzle/` (generate new ones with `pnpm db:generate` 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 after a schema change, and commit the generated SQL). Every live command is always persisted to
session's events, not a separate capture pipeline - every live command is always persisted for stats, `session_events` for stats, regardless of whether the session is ever replayed - replaying reads those same
regardless of whether the session is later saved as a named recording. 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 ## 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. 1. Log in with the shared secret.
2. On **Control**, scan for devices and connect. Move the sliders to control actuators live. 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 3. Click **Start session** before you begin; **End session** when done, and optionally name it so it's easy
recording; **End session** when done, and optionally name it to save it as a recording. to find later.
4. On **Recordings**, click replay on a saved recording, connect the devices you want to use, match them to 4. On **Sessions**, click replay on any completed session, connect the devices you want to use, match them
the recording's original device slots, and play back. to the session's original device slots, and play back.
5. **Sessions** and **Stats** show history and aggregated usage. 5. **Stats** shows aggregated usage across sessions and devices.
## Security & privacy notes ## Security & privacy notes
- This is a single shared-secret gate, not multi-user authentication - anyone with the secret has full - 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 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. 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 anywhere except directly between your browser and the devices it connects to, and between your browser
and this server. and this server.
- There's no login rate-limiting in this version - acceptable behind a private deployment, but worth - There's no login rate-limiting in this version - acceptable behind a private deployment, but worth
+24 -52
View File
@@ -2,8 +2,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BrandMark } from "@/components/layout/BrandMark"; import { BrandMark } from "@/components/layout/BrandMark";
import { listRecordings } from "@/lib/db/queries/recordings"; import { listSessions } from "@/lib/db/queries/sessions";
import { listPlaySessions } from "@/lib/db/queries/play-sessions";
import { getSessionsSummary } from "@/lib/db/queries/stats"; import { getSessionsSummary } from "@/lib/db/queries/stats";
// Always reflects live DB state for an authenticated, single-tenant app - // 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 const dynamic = "force-dynamic";
export default async function DashboardPage() { export default async function DashboardPage() {
const [recordings, sessions, summary] = await Promise.all([ const [sessions, summary] = await Promise.all([listSessions(), getSessionsSummary()]);
listRecordings(),
listPlaySessions(),
getSessionsSummary(),
]);
const recentRecordings = recordings.slice(0, 5);
const recentSessions = [...sessions].reverse().slice(0, 5); const recentSessions = [...sessions].reverse().slice(0, 5);
return ( return (
@@ -27,7 +21,7 @@ export default async function DashboardPage() {
<BrandMark size={48} animated className="rounded-[9px]" /> <BrandMark size={48} animated className="rounded-[9px]" />
<h1 className="font-heading text-3xl font-semibold">Welcome back</h1> <h1 className="font-heading text-3xl font-semibold">Welcome back</h1>
<p className="max-w-xl text-sm text-muted-foreground"> <p className="max-w-xl text-sm text-muted-foreground">
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. directly from your browser over Web Bluetooth.
</p> </p>
<Button asChild size="lg"> <Button asChild size="lg">
@@ -53,53 +47,31 @@ export default async function DashboardPage() {
</Card> </Card>
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Saved recordings</CardTitle> <CardTitle className="text-sm text-muted-foreground">Replays</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="bp-readout text-3xl">{recordings.length}</CardContent> <CardContent className="bp-readout text-3xl">{summary.totalReplays}</CardContent>
</Card> </Card>
</div> </div>
<div className="grid gap-6 lg:grid-cols-2"> <Card className="bp-glass">
<Card className="bp-glass"> <CardHeader>
<CardHeader> <CardTitle>Recent sessions</CardTitle>
<CardTitle>Recent recordings</CardTitle> </CardHeader>
</CardHeader> <CardContent className="flex flex-col">
<CardContent className="flex flex-col"> {recentSessions.length === 0 && <p className="text-sm text-muted-foreground">No sessions yet.</p>}
{recentRecordings.length === 0 && ( {recentSessions.map((s, i) => (
<p className="text-sm text-muted-foreground">Nothing saved yet.</p> <Link key={s.id} href={`/sessions/${s.id}`} className="group">
)} {i > 0 && <div className="bp-hairline" />}
{recentRecordings.map((r, i) => ( <div className="flex items-center justify-between px-1 py-2 text-sm">
<Link key={r.id} href={`/recordings/${r.id}`} className="group"> <span className="group-hover:text-primary">{s.name ?? `Session #${s.id}`}</span>
{i > 0 && <div className="bp-hairline" />} <span className="bp-readout text-xs text-muted-foreground">
<div className="flex items-center justify-between px-1 py-2 text-sm"> {new Date(s.startedAt).toLocaleDateString()}
<span className="group-hover:text-primary">{r.name}</span> </span>
<span className="bp-readout text-xs text-muted-foreground">{r.playCount} plays</span> </div>
</div> </Link>
</Link> ))}
))} </CardContent>
</CardContent> </Card>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle>Recent sessions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col">
{recentSessions.length === 0 && <p className="text-sm text-muted-foreground">No sessions yet.</p>}
{recentSessions.map((s, i) => (
<Link key={s.id} href={`/sessions/${s.id}`} className="group">
{i > 0 && <div className="bp-hairline" />}
<div className="flex items-center justify-between px-1 py-2 text-sm">
<span className="group-hover:text-primary">{s.name ?? `Session #${s.id}`}</span>
<span className="bp-readout text-xs text-muted-foreground">
{new Date(s.startedAt).toLocaleDateString()}
</span>
</div>
</Link>
))}
</CardContent>
</Card>
</div>
</div> </div>
); );
} }
-81
View File
@@ -1,81 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getRecording, getRecordingName } from "@/lib/db/queries/recordings";
import { Play } from "lucide-react";
export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const name = await getRecordingName(Number(id));
return { title: name ?? "Recording" };
}
function formatDuration(ms: number): string {
const totalSeconds = Math.round(ms / 1000);
return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`;
}
export default async function RecordingDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const result = await getRecording(Number(id));
if (!result) notFound();
const { recording } = result;
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-heading text-2xl font-semibold">{recording.name}</h1>
{recording.description && <p className="text-sm text-muted-foreground">{recording.description}</p>}
</div>
<Button asChild>
<Link href={`/recordings/${recording.id}/replay`}>
<Play className="size-4" /> Replay
</Link>
</Button>
</div>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Details</CardTitle>
</CardHeader>
<CardContent className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<span className="text-muted-foreground">Duration: </span>
{formatDuration(recording.durationMs)}
</div>
<div>
<span className="text-muted-foreground">Plays: </span>
{recording.playCount}
</div>
<div>
<span className="text-muted-foreground">Created: </span>
{new Date(recording.createdAt).toLocaleString()}
</div>
<div>
<span className="text-muted-foreground">Last played: </span>
{recording.lastPlayedAt ? new Date(recording.lastPlayedAt).toLocaleString() : "Never"}
</div>
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Devices in this recording</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1">
{recording.deviceSlots.map((slot) => (
<div key={slot.sourceSessionDeviceId} className="text-sm">
{slot.slotLabel} <span className="text-muted-foreground">({slot.recordedBleName})</span>
</div>
))}
</CardContent>
</Card>
</div>
);
}
-36
View File
@@ -1,36 +0,0 @@
import type { Metadata } from "next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RecordingsTable } from "@/components/recordings/RecordingsTable";
import { PageNav } from "@/components/shared/PageNav";
import { listRecordingsPage } from "@/lib/db/queries/recordings";
import { parsePage } from "@/lib/pagination";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Recordings" };
export default async function RecordingsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const page = parsePage((await searchParams).page);
const { items: recordings, pageSize, total } = await listRecordingsPage(page);
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="font-heading text-2xl font-semibold">Recordings</h1>
<p className="text-sm text-muted-foreground">Saved sessions you can replay against connected devices.</p>
</div>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Library</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<RecordingsTable recordings={recordings} />
<PageNav basePath="/recordings" page={page} pageSize={pageSize} total={total} />
</CardContent>
</Card>
</div>
);
}
+35 -8
View File
@@ -1,15 +1,18 @@
import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart"; import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart";
import { getPlaySessionDetail, getPlaySessionName } from "@/lib/db/queries/play-sessions"; import { getSessionDetail, getSessionName } from "@/lib/db/queries/sessions";
import { getSessionTimeline } from "@/lib/db/queries/stats"; import { getSessionTimeline } from "@/lib/db/queries/stats";
import { Play } from "lucide-react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const name = await getPlaySessionName(Number(id)); const name = await getSessionName(Number(id));
return { title: name ?? `Session #${id}` }; return { title: name ?? `Session #${id}` };
} }
@@ -22,16 +25,40 @@ function formatDuration(ms: number | null): string {
export default async function SessionDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function SessionDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const sessionId = Number(id); const sessionId = Number(id);
const [detail, timeline] = await Promise.all([getPlaySessionDetail(sessionId), getSessionTimeline(sessionId)]); const [detail, timeline] = await Promise.all([getSessionDetail(sessionId), getSessionTimeline(sessionId)]);
if (!detail) notFound(); if (!detail) notFound();
const canReplay = detail.session.status === "completed" && detail.session.durationMs !== null;
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div className="flex items-center justify-between gap-4">
<h1 className="font-heading text-2xl font-semibold">{detail.session.name ?? `Session #${detail.session.id}`}</h1> <div>
<p className="text-sm text-muted-foreground"> <h1 className="font-heading text-2xl font-semibold">{detail.session.name ?? `Session #${detail.session.id}`}</h1>
{detail.session.kind} · {detail.session.status} · {formatDuration(detail.session.durationMs)} <p className="text-sm text-muted-foreground">
</p> {detail.session.status} · {formatDuration(detail.session.durationMs)}
{detail.session.kind === "replay" && detail.replayedFromName !== undefined && (
<> · replayed from {detail.replayedFromName ?? `session #${detail.session.replayedSessionId}`}</>
)}
{detail.session.playCount > 0 && (
<>
{" "}
· replayed {detail.session.playCount}×
{detail.session.lastPlayedAt && ` (last ${new Date(detail.session.lastPlayedAt).toLocaleString()})`}
</>
)}
</p>
{detail.session.description && (
<p className="mt-1 text-sm text-muted-foreground">{detail.session.description}</p>
)}
</div>
{canReplay && (
<Button asChild>
<Link href={`/sessions/${detail.session.id}/replay`}>
<Play className="size-4" /> Replay
</Link>
</Button>
)}
</div> </div>
<Card className="bp-glass"> <Card className="bp-glass">
@@ -1,10 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ReplayPlayerLoader } from "@/components/recordings/ReplayPlayerLoader"; import { ReplayPlayerLoader } from "@/components/sessions/ReplayPlayerLoader";
import { getRecordingName } from "@/lib/db/queries/recordings"; import { getSessionName } from "@/lib/db/queries/sessions";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const name = await getRecordingName(Number(id)); const name = await getSessionName(Number(id));
return { title: name ? `Replay ${name}` : "Replay" }; return { title: name ? `Replay ${name}` : "Replay" };
} }
@@ -13,7 +13,7 @@ export default async function ReplayPage({ params }: { params: Promise<{ id: str
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<h1 className="font-heading text-2xl font-semibold">Replay</h1> <h1 className="font-heading text-2xl font-semibold">Replay</h1>
<ReplayPlayerLoader recordingId={Number(id)} /> <ReplayPlayerLoader sessionId={Number(id)} />
</div> </div>
); );
} }
+2 -2
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionsTable } from "@/components/sessions/SessionsTable"; import { SessionsTable } from "@/components/sessions/SessionsTable";
import { PageNav } from "@/components/shared/PageNav"; import { PageNav } from "@/components/shared/PageNav";
import { listPlaySessionsPage } from "@/lib/db/queries/play-sessions"; import { listSessionsPage } from "@/lib/db/queries/sessions";
import { parsePage } from "@/lib/pagination"; import { parsePage } from "@/lib/pagination";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -14,7 +14,7 @@ export default async function SessionsPage({
searchParams: Promise<{ page?: string }>; searchParams: Promise<{ page?: string }>;
}) { }) {
const page = parsePage((await searchParams).page); const page = parsePage((await searchParams).page);
const { items: sessions, pageSize, total } = await listPlaySessionsPage(page); const { items: sessions, pageSize, total } = await listSessionsPage(page);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
+3 -9
View File
@@ -2,18 +2,16 @@ import type { Metadata } from "next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SessionsSummaryCards } from "@/components/stats/SessionsSummaryCards"; import { SessionsSummaryCards } from "@/components/stats/SessionsSummaryCards";
import { DeviceUsageTable } from "@/components/stats/DeviceUsageTable"; import { DeviceUsageTable } from "@/components/stats/DeviceUsageTable";
import { RecordingLibraryStats } from "@/components/stats/RecordingLibraryStats"; import { getDeviceCommandCounts, getDeviceUsageStats, getSessionsSummary } from "@/lib/db/queries/stats";
import { getDeviceCommandCounts, getDeviceUsageStats, getRecordingLibraryStats, getSessionsSummary } from "@/lib/db/queries/stats";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Stats" }; export const metadata: Metadata = { title: "Stats" };
export default async function StatsPage() { export default async function StatsPage() {
const [sessionsSummary, deviceUsage, commandCounts, recordingStats] = await Promise.all([ const [sessionsSummary, deviceUsage, commandCounts] = await Promise.all([
getSessionsSummary(), getSessionsSummary(),
getDeviceUsageStats(), getDeviceUsageStats(),
getDeviceCommandCounts(), getDeviceCommandCounts(),
getRecordingLibraryStats(),
]); ]);
const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount])); const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount]));
@@ -23,14 +21,13 @@ export default async function StatsPage() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <div>
<h1 className="font-heading text-2xl font-semibold">Stats</h1> <h1 className="font-heading text-2xl font-semibold">Stats</h1>
<p className="text-sm text-muted-foreground">Usage across sessions, devices, and your recording library.</p> <p className="text-sm text-muted-foreground">Usage across sessions and devices.</p>
</div> </div>
<Tabs defaultValue="sessions"> <Tabs defaultValue="sessions">
<TabsList className="bp-glass"> <TabsList className="bp-glass">
<TabsTrigger value="sessions">Sessions</TabsTrigger> <TabsTrigger value="sessions">Sessions</TabsTrigger>
<TabsTrigger value="devices">Devices</TabsTrigger> <TabsTrigger value="devices">Devices</TabsTrigger>
<TabsTrigger value="recordings">Recordings</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="sessions" className="pt-4"> <TabsContent value="sessions" className="pt-4">
<SessionsSummaryCards summary={sessionsSummary} /> <SessionsSummaryCards summary={sessionsSummary} />
@@ -38,9 +35,6 @@ export default async function StatsPage() {
<TabsContent value="devices" className="pt-4"> <TabsContent value="devices" className="pt-4">
<DeviceUsageTable devices={devices} /> <DeviceUsageTable devices={devices} />
</TabsContent> </TabsContent>
<TabsContent value="recordings" className="pt-4">
<RecordingLibraryStats stats={recordingStats} />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
); );
-56
View File
@@ -1,56 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deletePlaySession, endPlaySession, getPlaySessionDetail } from "@/lib/db/queries/play-sessions";
import { getRecordingsForSession } from "@/lib/db/queries/recordings";
import { withRouteLogging } from "@/lib/api/with-route-logging";
import { createLogger } from "@/lib/logger";
const log = createLogger("play-sessions");
const patchSchema = z.object({ status: z.enum(["completed", "aborted"]) });
export const GET = withRouteLogging(
"play-sessions.get",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const detail = await getPlaySessionDetail(Number(id));
if (!detail) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(detail);
},
);
export const PATCH = withRouteLogging(
"play-sessions.end",
async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "status must be completed or aborted" }, { status: 400 });
}
const updated = await endPlaySession(Number(id), parsed.data);
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ playSession: updated });
},
);
export const DELETE = withRouteLogging(
"play-sessions.delete",
async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const cascade = new URL(req.url).searchParams.get("cascade") === "true";
try {
await deletePlaySession(Number(id), { cascade });
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("FOREIGN KEY") || message.includes("SQLITE_CONSTRAINT")) {
log.warn("blocked session delete due to referencing recording", { sessionId: id, cascade });
const blockingRecordings = await getRecordingsForSession(Number(id));
return NextResponse.json(
{ error: "cannot delete a session that a saved recording still references", recordings: blockingRecordings },
{ status: 409 },
);
}
throw err;
}
},
);
-42
View File
@@ -1,42 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deleteRecording, getRecording, renameRecording } from "@/lib/db/queries/recordings";
import { withRouteLogging } from "@/lib/api/with-route-logging";
const patchSchema = z.object({
name: z.string().min(1).max(160).optional(),
description: z.string().max(2000).optional(),
});
export const GET = withRouteLogging(
"recordings.get",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const result = await getRecording(Number(id));
if (!result) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(result);
},
);
export const PATCH = withRouteLogging(
"recordings.update",
async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "invalid body" }, { status: 400 });
}
const updated = await renameRecording(Number(id), parsed.data);
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ recording: updated });
},
);
export const DELETE = withRouteLogging(
"recordings.delete",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
await deleteRecording(Number(id));
return NextResponse.json({ ok: true });
},
);
-24
View File
@@ -1,24 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createRecording, listRecordings } from "@/lib/db/queries/recordings";
import { withRouteLogging } from "@/lib/api/with-route-logging";
const bodySchema = z.object({
sourcePlaySessionId: z.number().int().positive(),
name: z.string().min(1).max(160),
description: z.string().max(2000).optional(),
});
export const GET = withRouteLogging("recordings.list", async () => {
const rows = await listRecordings();
return NextResponse.json({ recordings: rows });
});
export const POST = withRouteLogging("recordings.create", async (req: Request) => {
const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const recording = await createRecording(parsed.data);
return NextResponse.json({ recording }, { status: 201 });
});
@@ -15,7 +15,7 @@ const eventSchema = z.object({
const bodySchema = z.object({ events: z.array(eventSchema).max(2000) }); const bodySchema = z.object({ events: z.array(eventSchema).max(2000) });
export const POST = withRouteLogging( export const POST = withRouteLogging(
"play-sessions.events.append", "sessions.events.append",
async (req: Request, { params }: { params: Promise<{ id: string }> }) => { async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params; const { id } = await params;
const parsed = bodySchema.safeParse(await req.json().catch(() => null)); const parsed = bodySchema.safeParse(await req.json().catch(() => null));
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { getSessionForReplay, incrementSessionPlayCount } from "@/lib/db/queries/sessions";
import { withRouteLogging } from "@/lib/api/with-route-logging";
export const GET = withRouteLogging(
"sessions.replay-data",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const result = await getSessionForReplay(Number(id));
if (!result) return NextResponse.json({ error: "not found" }, { status: 404 });
if (result.session.durationMs === null) {
return NextResponse.json({ error: "session has no recorded duration yet" }, { status: 409 });
}
return NextResponse.json(result);
},
);
/**
* Marks the session as replayed (bumps playCount/lastPlayedAt). Replaying
* itself creates no session row or events of its own - the client just
* plays this session's already-recorded events back against newly-mapped
* devices, so this is the only DB write a replay run causes.
*/
export const POST = withRouteLogging(
"sessions.replay-start",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
await incrementSessionPlayCount(Number(id), Date.now());
return NextResponse.json({ ok: true });
},
);
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { deleteSession, endSession, getSessionDetail, renameSession } from "@/lib/db/queries/sessions";
import { withRouteLogging } from "@/lib/api/with-route-logging";
const patchSchema = z
.object({
status: z.enum(["completed", "aborted"]).optional(),
name: z.string().min(1).max(160).optional(),
description: z.string().max(2000).optional(),
})
.refine((v) => v.status !== undefined || v.name !== undefined || v.description !== undefined, {
message: "at least one of status, name, description is required",
});
export const GET = withRouteLogging(
"sessions.get",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const detail = await getSessionDetail(Number(id));
if (!detail) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(detail);
},
);
export const PATCH = withRouteLogging(
"sessions.update",
async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const parsed = patchSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { status, name, description } = parsed.data;
let updated;
if (status !== undefined) {
updated = await endSession(Number(id), { status });
}
if (name !== undefined || description !== undefined) {
updated = await renameSession(Number(id), { name, description });
}
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json({ session: updated });
},
);
export const DELETE = withRouteLogging(
"sessions.delete",
async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
await deleteSession(Number(id));
return NextResponse.json({ ok: true });
},
);
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { listPlaySessions, startPlaySession } from "@/lib/db/queries/play-sessions"; import { listSessions, startSession } from "@/lib/db/queries/sessions";
import { withRouteLogging } from "@/lib/api/with-route-logging"; import { withRouteLogging } from "@/lib/api/with-route-logging";
const deviceInputSchema = z.object({ const deviceInputSchema = z.object({
@@ -11,22 +11,20 @@ const deviceInputSchema = z.object({
}); });
const bodySchema = z.object({ const bodySchema = z.object({
kind: z.enum(["live", "replay"]),
replayedRecordingId: z.number().int().positive().optional(),
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
devices: z.array(deviceInputSchema).min(1), devices: z.array(deviceInputSchema).min(1),
}); });
export const GET = withRouteLogging("play-sessions.list", async () => { export const GET = withRouteLogging("sessions.list", async () => {
const rows = await listPlaySessions(); const rows = await listSessions();
return NextResponse.json({ playSessions: rows }); return NextResponse.json({ sessions: rows });
}); });
export const POST = withRouteLogging("play-sessions.start", async (req: Request) => { export const POST = withRouteLogging("sessions.start", async (req: Request) => {
const parsed = bodySchema.safeParse(await req.json().catch(() => null)); const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 }); return NextResponse.json({ error: parsed.error.message }, { status: 400 });
} }
const result = await startPlaySession(parsed.data); const result = await startSession(parsed.data);
return NextResponse.json(result, { status: 201 }); return NextResponse.json(result, { status: 201 });
}); });
-8
View File
@@ -1,8 +0,0 @@
import { NextResponse } from "next/server";
import { getRecordingLibraryStats } from "@/lib/db/queries/stats";
import { withRouteLogging } from "@/lib/api/with-route-logging";
export const GET = withRouteLogging("stats.recordings", async () => {
const stats = await getRecordingLibraryStats();
return NextResponse.json(stats);
});
+7 -8
View File
@@ -20,10 +20,10 @@ import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
import { DeviceScanPanel } from "./DeviceScanPanel"; import { DeviceScanPanel } from "./DeviceScanPanel";
import { DeviceCard } from "./DeviceCard"; import { DeviceCard } from "./DeviceCard";
import { RecordControls } from "./RecordControls"; import { RecordControls } from "./RecordControls";
import { SaveRecordingDialog } from "@/components/recordings/SaveRecordingDialog"; import { NameSessionDialog } from "./NameSessionDialog";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
interface ActivePlaySession { interface ActiveSession {
id: number; id: number;
startedAt: number; startedAt: number;
sessionDeviceIdByDeviceIndex: Map<number, number>; sessionDeviceIdByDeviceIndex: Map<number, number>;
@@ -49,7 +49,7 @@ export function ButtplugConsole() {
const setRecording = useButtplugStore((s) => s.setRecording); const setRecording = useButtplugStore((s) => s.setRecording);
const storeError = useButtplugStore((s) => s.error); const storeError = useButtplugStore((s) => s.error);
const [activeSession, setActiveSession] = useState<ActivePlaySession | null>(null); const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
const [elapsedMs, setElapsedMs] = useState(0); const [elapsedMs, setElapsedMs] = useState(0);
const [sessionBusy, setSessionBusy] = useState(false); const [sessionBusy, setSessionBusy] = useState(false);
const [savePromptSessionId, setSavePromptSessionId] = useState<number | null>(null); const [savePromptSessionId, setSavePromptSessionId] = useState<number | null>(null);
@@ -105,14 +105,13 @@ export function ButtplugConsole() {
try { try {
const deviceList = Object.values(devices); const deviceList = Object.values(devices);
const body = { const body = {
kind: "live" as const,
devices: deviceList.map((d) => ({ devices: deviceList.map((d) => ({
slotLabel: d.displayName ?? d.name, slotLabel: d.displayName ?? d.name,
bleName: d.name, bleName: d.name,
capabilities: { outputs: [...new Set(d.actuators.map((a) => a.outputType))], featureCount: d.actuators.length }, capabilities: { outputs: [...new Set(d.actuators.map((a) => a.outputType))], featureCount: d.actuators.length },
})), })),
}; };
const res = await fetch("/api/play-sessions", { const res = await fetch("/api/sessions", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -151,7 +150,7 @@ export function ButtplugConsole() {
setSessionBusy(true); setSessionBusy(true);
eventBuffer.stop(); eventBuffer.stop();
setRecording(false); setRecording(false);
await fetch(`/api/play-sessions/${activeSession.id}`, { await fetch(`/api/sessions/${activeSession.id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "completed" }), body: JSON.stringify({ status: "completed" }),
@@ -270,8 +269,8 @@ export function ButtplugConsole() {
</div> </div>
)} )}
<SaveRecordingDialog <NameSessionDialog
playSessionId={savePromptSessionId} sessionId={savePromptSessionId}
onClose={() => setSavePromptSessionId(null)} onClose={() => setSavePromptSessionId(null)}
onSaved={() => setSavePromptSessionId(null)} onSaved={() => setSavePromptSessionId(null)}
/> />
@@ -14,50 +14,50 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { toast } from "sonner"; import { toast } from "sonner";
interface SaveRecordingDialogProps { interface NameSessionDialogProps {
playSessionId: number | null; sessionId: number | null;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
} }
export function SaveRecordingDialog({ playSessionId, onClose, onSaved }: SaveRecordingDialogProps) { export function NameSessionDialog({ sessionId, onClose, onSaved }: NameSessionDialogProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
async function handleSave() { async function handleSave() {
if (!playSessionId || name.trim().length === 0) return; if (!sessionId || name.trim().length === 0) return;
setSaving(true); setSaving(true);
const res = await fetch("/api/recordings", { const res = await fetch(`/api/sessions/${sessionId}`, {
method: "POST", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourcePlaySessionId: playSessionId, name: name.trim(), description }), body: JSON.stringify({ name: name.trim(), description }),
}); });
setSaving(false); setSaving(false);
if (res.ok) { if (res.ok) {
toast.success("Recording saved"); toast.success("Session named");
onSaved(); onSaved();
} else { } else {
toast.error("Could not save recording"); toast.error("Could not name session");
} }
} }
return ( return (
<Dialog open={playSessionId !== null} onOpenChange={(open) => !open && onClose()}> <Dialog open={sessionId !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Save session as a recording</DialogTitle> <DialogTitle>Name this session</DialogTitle>
<DialogDescription>Give it a name so you can find and replay it later.</DialogDescription> <DialogDescription>Give it a name so you can find and replay it later.</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="recording-name">Name</Label> <Label htmlFor="session-name">Name</Label>
<Input id="recording-name" value={name} onChange={(e) => setName(e.target.value)} autoFocus /> <Input id="session-name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="recording-description">Description (optional)</Label> <Label htmlFor="session-description">Description (optional)</Label>
<Input <Input
id="recording-description" id="session-description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
@@ -68,7 +68,7 @@ export function SaveRecordingDialog({ playSessionId, onClose, onSaved }: SaveRec
Skip Skip
</Button> </Button>
<Button onClick={handleSave} disabled={saving || name.trim().length === 0}> <Button onClick={handleSave} disabled={saving || name.trim().length === 0}>
{saving ? "Saving..." : "Save recording"} {saving ? "Saving..." : "Save name"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+1 -1
View File
@@ -20,7 +20,7 @@ export function RecordControls({ active, elapsedLabel, disabled, busy, onStart,
<Circle className="bp-pulse size-2.5 fill-destructive text-destructive" /> <Circle className="bp-pulse size-2.5 fill-destructive text-destructive" />
Session live · {elapsedLabel} Session live · {elapsedLabel}
</span> </span>
<Button variant="outline" size="sm" onClick={onEnd} disabled={busy}> <Button variant="outline" onClick={onEnd} disabled={busy}>
<Square className="size-3.5" /> End session <Square className="size-3.5" /> End session
</Button> </Button>
</div> </div>
-1
View File
@@ -6,7 +6,6 @@ import { ChevronRight, House } from "lucide-react";
const SECTION_LABELS: Record<string, string> = { const SECTION_LABELS: Record<string, string> = {
control: "Control", control: "Control",
recordings: "Recordings",
sessions: "Sessions", sessions: "Sessions",
stats: "Stats", stats: "Stats",
devices: "Devices", devices: "Devices",
-1
View File
@@ -14,7 +14,6 @@ import { cn } from "@/lib/utils";
const LINKS = [ const LINKS = [
{ href: "/", label: "Dashboard" }, { href: "/", label: "Dashboard" },
{ href: "/control", label: "Control" }, { href: "/control", label: "Control" },
{ href: "/recordings", label: "Recordings" },
{ href: "/sessions", label: "Sessions" }, { href: "/sessions", label: "Sessions" },
{ href: "/stats", label: "Stats" }, { href: "/stats", label: "Stats" },
{ href: "/devices", label: "Devices" }, { href: "/devices", label: "Devices" },
-91
View File
@@ -1,91 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Play, Trash2 } from "lucide-react";
import { toast } from "sonner";
export interface RecordingRow {
id: number;
name: string;
durationMs: number;
playCount: number;
lastPlayedAt: number | null;
createdAt: number;
}
function formatDuration(ms: number): string {
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
export function RecordingsTable({ recordings }: { recordings: RecordingRow[] }) {
const router = useRouter();
async function handleDelete(id: number) {
const res = await fetch(`/api/recordings/${id}`, { method: "DELETE" });
if (res.ok) {
toast.success("Recording deleted");
router.refresh();
} else {
toast.error("Could not delete recording");
}
}
if (recordings.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No recordings yet - start a session on the Control page and save it when you&apos;re done.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Plays</TableHead>
<TableHead>Last played</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recordings.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">
<Link href={`/recordings/${r.id}`} className="hover:underline">
{r.name}
</Link>
</TableCell>
<TableCell className="bp-readout">{formatDuration(r.durationMs)}</TableCell>
<TableCell>
<Badge variant="secondary" className="bp-readout">
{r.playCount}
</Badge>
</TableCell>
<TableCell className="bp-readout text-muted-foreground">
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
</TableCell>
<TableCell className="flex justify-end gap-1">
<Button asChild variant="ghost" size="icon-sm">
<Link href={`/recordings/${r.id}/replay`} aria-label="Replay">
<Play className="size-3.5" />
</Link>
</Button>
<Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(r.id)} aria-label="Delete">
<Trash2 className="size-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -12,12 +12,11 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { autoMapDeviceSlots } from "@/lib/buttplug/device-remap"; import { autoMapDeviceSlots } from "@/lib/buttplug/device-remap";
import type { RecordingDeviceSlot } from "@/lib/db/schema"; import type { ReplayDeviceSlot, ConnectedDeviceInfo } from "@/lib/buttplug/types";
import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
interface DeviceRemapDialogProps { interface DeviceRemapDialogProps {
open: boolean; open: boolean;
deviceSlots: RecordingDeviceSlot[]; deviceSlots: ReplayDeviceSlot[];
connectedDevices: ConnectedDeviceInfo[]; connectedDevices: ConnectedDeviceInfo[];
onCancel: () => void; onCancel: () => void;
onConfirm: (mapping: Map<number, number>) => void; onConfirm: (mapping: Map<number, number>) => void;
@@ -8,21 +8,19 @@ import { Slider } from "@/components/ui/slider";
import { DeviceScanPanel } from "@/components/control/DeviceScanPanel"; import { DeviceScanPanel } from "@/components/control/DeviceScanPanel";
import { DeviceRemapDialog } from "./DeviceRemapDialog"; import { DeviceRemapDialog } from "./DeviceRemapDialog";
import { disconnectAll, getButtplugClientHandle, getDevice, startScanning, stopScanning } from "@/lib/buttplug/client"; import { disconnectAll, getButtplugClientHandle, getDevice, startScanning, stopScanning } from "@/lib/buttplug/client";
import { eventBuffer } from "@/lib/buttplug/event-buffer"; import { SessionPlayer, type SessionEventRow } from "@/lib/buttplug/player";
import { RecordingPlayer, type RecordingEventRow } from "@/lib/buttplug/player";
import { useButtplugStore } from "@/lib/buttplug/store"; import { useButtplugStore } from "@/lib/buttplug/store";
import type { ActuatorInfo } from "@/lib/buttplug/types"; import type { ActuatorInfo, ReplayDeviceSlot } from "@/lib/buttplug/types";
import type { RecordingDeviceSlot } from "@/lib/db/schema";
import { Pause, Play } from "lucide-react"; import { Pause, Play } from "lucide-react";
interface RecordingResponse { interface ReplayDataResponse {
recording: { session: {
id: number; id: number;
name: string; name: string | null;
durationMs: number; durationMs: number;
deviceSlots: RecordingDeviceSlot[];
}; };
events: RecordingEventRow[]; deviceSlots: ReplayDeviceSlot[];
events: SessionEventRow[];
} }
function formatTime(ms: number): string { function formatTime(ms: number): string {
@@ -30,29 +28,28 @@ function formatTime(ms: number): string {
return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`; return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`;
} }
export function ReplayPlayer({ recordingId }: { recordingId: number }) { export function ReplayPlayer({ sessionId }: { sessionId: number }) {
const scanning = useButtplugStore((s) => s.scanning); const scanning = useButtplugStore((s) => s.scanning);
const devices = useButtplugStore((s) => s.devices); const devices = useButtplugStore((s) => s.devices);
const setActuatorValue = useButtplugStore((s) => s.setActuatorValue); const setActuatorValue = useButtplugStore((s) => s.setActuatorValue);
const removeDevice = useButtplugStore((s) => s.removeDevice); const removeDevice = useButtplugStore((s) => s.removeDevice);
const connectedDevices = useMemo(() => Object.values(devices), [devices]); const connectedDevices = useMemo(() => Object.values(devices), [devices]);
const [data, setData] = useState<RecordingResponse | null>(null); const [data, setData] = useState<ReplayDataResponse | null>(null);
const [showRemap, setShowRemap] = useState(false); const [showRemap, setShowRemap] = useState(false);
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [player, setPlayer] = useState<RecordingPlayer | null>(null); const [player, setPlayer] = useState<SessionPlayer | null>(null);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0); const [elapsedMs, setElapsedMs] = useState(0);
const [replayTargets, setReplayTargets] = useState<{ slotLabel: string; deviceName: string }[]>([]); const [replayTargets, setReplayTargets] = useState<{ slotLabel: string; deviceName: string }[]>([]);
const [replayDeviceIndexes, setReplayDeviceIndexes] = useState<number[]>([]); const [replayDeviceIndexes, setReplayDeviceIndexes] = useState<number[]>([]);
const [activeSessionId, setActiveSessionId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
fetch(`/api/recordings/${recordingId}`) fetch(`/api/sessions/${sessionId}/replay`)
.then((r) => r.json()) .then((r) => r.json())
.then(setData) .then(setData)
.catch(() => toast.error("Could not load recording")); .catch(() => toast.error("Could not load session"));
}, [recordingId]); }, [sessionId]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -76,41 +73,21 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) {
setStarting(true); setStarting(true);
try { try {
const orderedSlots = data.recording.deviceSlots.filter((s) => mapping.has(s.sourceSessionDeviceId)); const orderedSlots = data.deviceSlots.filter((s) => mapping.has(s.sourceSessionDeviceId));
const targets = orderedSlots.map((slot) => { const targets = orderedSlots.map((slot) => {
const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!; const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!;
const device = connectedDevices.find((d) => d.index === deviceIndex)!; const device = connectedDevices.find((d) => d.index === deviceIndex)!;
return { slotLabel: slot.slotLabel, deviceName: device.displayName ?? device.name }; return { slotLabel: slot.slotLabel, deviceName: device.displayName ?? device.name };
}); });
const body = { // Replaying creates no session or events of its own - it just plays this
kind: "replay" as const, // session's already-recorded events back onto the mapped devices, and
replayedRecordingId: data.recording.id, // marks it as replayed (playCount/lastPlayedAt).
devices: orderedSlots.map((slot, i) => ({ slotLabel: slot.slotLabel, bleName: targets[i].deviceName })), void fetch(`/api/sessions/${data.session.id}/replay`, { method: "POST" }).catch(() => {});
};
const res = await fetch("/api/play-sessions", { // `mapping` is already keyed by the source session's session_device id
method: "POST", // (see DeviceRemapDialog), exactly what SessionPlayer needs.
headers: { "Content-Type": "application/json" }, const sessionDeviceIdToDeviceIndex = mapping;
body: JSON.stringify(body),
});
if (!res.ok) {
toast.error("Could not start replay session");
return;
}
const result: { session: { id: number; startedAt: number }; sessionDevices: { id: number }[] } =
await res.json();
eventBuffer.start(result.session.id, result.session.startedAt);
const sessionDeviceIdToDeviceIndex = new Map<number, number>();
orderedSlots.forEach((slot, i) => {
const deviceIndex = mapping.get(slot.sourceSessionDeviceId)!;
const newSessionDeviceId = result.sessionDevices[i]?.id;
if (newSessionDeviceId !== undefined) {
eventBuffer.registerSessionDevice(deviceIndex, newSessionDeviceId);
sessionDeviceIdToDeviceIndex.set(slot.sourceSessionDeviceId, deviceIndex);
}
});
const actuatorsByDeviceIndex = new Map<number, ActuatorInfo[]>( const actuatorsByDeviceIndex = new Map<number, ActuatorInfo[]>(
connectedDevices.map((d) => [d.index, d.actuators]), connectedDevices.map((d) => [d.index, d.actuators]),
@@ -129,28 +106,21 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) {
); );
const { runtime } = await getButtplugClientHandle(); const { runtime } = await getButtplugClientHandle();
const instance = new RecordingPlayer({ const instance = new SessionPlayer({
events: data.events, events: data.events,
durationMs: data.recording.durationMs, durationMs: data.session.durationMs,
sessionDeviceIdToDeviceIndex, sessionDeviceIdToDeviceIndex,
actuatorsByDeviceIndex, actuatorsByDeviceIndex,
runtime, runtime,
onProgress: (elapsed) => setElapsedMs(elapsed), onProgress: (elapsed) => setElapsedMs(elapsed),
onError: (message) => toast.error(`Replay command failed: ${message}`), onError: (message) => toast.error(`Replay command failed: ${message}`),
onComplete: async () => { onComplete: () => {
setPlaying(false); setPlaying(false);
eventBuffer.stop();
await fetch(`/api/play-sessions/${result.session.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "completed" }),
}).catch(() => {});
toast.success("Replay finished"); toast.success("Replay finished");
}, },
}); });
setReplayTargets(targets); setReplayTargets(targets);
setReplayDeviceIndexes([...deviceIndexes]); setReplayDeviceIndexes([...deviceIndexes]);
setActiveSessionId(result.session.id);
setPlayer(instance); setPlayer(instance);
instance.play(); instance.play();
setPlaying(true); setPlaying(true);
@@ -162,20 +132,20 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) {
} }
if (!data) { if (!data) {
return <p className="text-sm text-muted-foreground">Loading recording...</p>; return <p className="text-sm text-muted-foreground">Loading session...</p>;
} }
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle>{data.recording.name}</CardTitle> <CardTitle>{data.session.name ?? `Session #${data.session.id}`}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
{!player ? ( {!player ? (
<> <>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Connect the devices you want to replay onto, then match them to the recording&apos;s Connect the devices you want to replay onto, then match them to the session&apos;s
original device slots. original device slots.
</p> </p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -211,12 +181,12 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) {
)} )}
<Slider <Slider
value={[elapsedMs]} value={[elapsedMs]}
max={data.recording.durationMs} max={data.session.durationMs}
onValueChange={([v]) => player.seek(v)} onValueChange={([v]) => player.seek(v)}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="bp-readout text-xs text-muted-foreground"> <span className="bp-readout text-xs text-muted-foreground">
{formatTime(elapsedMs)} / {formatTime(data.recording.durationMs)} {formatTime(elapsedMs)} / {formatTime(data.session.durationMs)}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@@ -246,14 +216,6 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) {
player.stop(); player.stop();
setPlaying(false); setPlaying(false);
setElapsedMs(0); setElapsedMs(0);
eventBuffer.stop();
if (activeSessionId !== null) {
await fetch(`/api/play-sessions/${activeSessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "aborted" }),
}).catch(() => {});
}
// Fully disconnect every device that took part in this replay, // Fully disconnect every device that took part in this replay,
// not just stop the player - see handleDisconnectDevice in // not just stop the player - see handleDisconnectDevice in
// ButtplugConsole for why "stop + remove from store" is the // ButtplugConsole for why "stop + remove from store" is the
@@ -268,7 +230,6 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) {
setPlayer(null); setPlayer(null);
setReplayTargets([]); setReplayTargets([]);
setReplayDeviceIndexes([]); setReplayDeviceIndexes([]);
setActiveSessionId(null);
}} }}
> >
Stop Stop
@@ -282,7 +243,7 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) {
<DeviceRemapDialog <DeviceRemapDialog
open={showRemap} open={showRemap}
deviceSlots={data.recording.deviceSlots} deviceSlots={data.deviceSlots}
connectedDevices={connectedDevices} connectedDevices={connectedDevices}
onCancel={() => setShowRemap(false)} onCancel={() => setShowRemap(false)}
onConfirm={(mapping) => void handleConfirmRemap(mapping)} onConfirm={(mapping) => void handleConfirmRemap(mapping)}
@@ -8,6 +8,6 @@ const ReplayPlayer = dynamic(() => import("./ReplayPlayer").then((m) => m.Replay
loading: () => <Skeleton className="h-64 w-full" />, loading: () => <Skeleton className="h-64 w-full" />,
}); });
export function ReplayPlayerLoader({ recordingId }: { recordingId: number }) { export function ReplayPlayerLoader({ sessionId }: { sessionId: number }) {
return <ReplayPlayer recordingId={recordingId} />; return <ReplayPlayer sessionId={sessionId} />;
} }
+33 -41
View File
@@ -14,23 +14,23 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Trash2 } from "lucide-react"; import { Play, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
interface BlockingRecording {
id: number;
name: string;
}
export interface SessionRow { export interface SessionRow {
id: number; id: number;
name: string | null; name: string | null;
kind: "live" | "replay";
status: "active" | "completed" | "aborted"; status: "active" | "completed" | "aborted";
startedAt: number; startedAt: number;
durationMs: number | null; durationMs: number | null;
} }
const STATUS_VARIANT = {
active: "default",
completed: "secondary",
aborted: "destructive",
} as const;
function formatDuration(ms: number | null): string { function formatDuration(ms: number | null): string {
if (ms === null) return "-"; if (ms === null) return "-";
const totalSeconds = Math.round(ms / 1000); const totalSeconds = Math.round(ms / 1000);
@@ -41,30 +41,17 @@ function formatDuration(ms: number | null): string {
export function SessionsTable({ sessions }: { sessions: SessionRow[] }) { export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
const router = useRouter(); const router = useRouter();
const [conflict, setConflict] = useState<{ id: number; recordings: BlockingRecording[] } | null>(null); const [pendingDelete, setPendingDelete] = useState<SessionRow | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
async function handleDelete(id: number) { async function handleDelete() {
const res = await fetch(`/api/play-sessions/${id}`, { method: "DELETE" }); if (!pendingDelete) return;
if (res.ok) {
toast.success("Session deleted");
router.refresh();
} else if (res.status === 409) {
const body: { recordings?: BlockingRecording[] } = await res.json().catch(() => ({}));
setConflict({ id, recordings: body.recordings ?? [] });
} else {
toast.error("Could not delete session");
}
}
async function handleCascadeDelete() {
if (!conflict) return;
setDeleting(true); setDeleting(true);
const res = await fetch(`/api/play-sessions/${conflict.id}?cascade=true`, { method: "DELETE" }); const res = await fetch(`/api/sessions/${pendingDelete.id}`, { method: "DELETE" });
setDeleting(false); setDeleting(false);
if (res.ok) { if (res.ok) {
toast.success("Session and recording deleted"); toast.success("Session deleted");
setConflict(null); setPendingDelete(null);
router.refresh(); router.refresh();
} else { } else {
toast.error("Could not delete session"); toast.error("Could not delete session");
@@ -81,7 +68,6 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Session</TableHead> <TableHead>Session</TableHead>
<TableHead>Kind</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Started</TableHead> <TableHead>Started</TableHead>
<TableHead>Duration</TableHead> <TableHead>Duration</TableHead>
@@ -97,15 +83,21 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
</Link> </Link>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={s.kind === "live" ? "default" : "secondary"}>{s.kind}</Badge> <Badge variant={STATUS_VARIANT[s.status]}>{s.status}</Badge>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{s.status}</TableCell>
<TableCell className="bp-readout text-muted-foreground"> <TableCell className="bp-readout text-muted-foreground">
{new Date(s.startedAt).toLocaleString()} {new Date(s.startedAt).toLocaleString()}
</TableCell> </TableCell>
<TableCell className="bp-readout">{formatDuration(s.durationMs)}</TableCell> <TableCell className="bp-readout">{formatDuration(s.durationMs)}</TableCell>
<TableCell className="text-right"> <TableCell className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(s.id)} aria-label="Delete"> {s.status === "completed" && s.durationMs !== null && (
<Button asChild variant="ghost" size="icon-sm">
<Link href={`/sessions/${s.id}/replay`} aria-label="Replay">
<Play className="size-3.5" />
</Link>
</Button>
)}
<Button variant="ghost" size="icon-sm" onClick={() => setPendingDelete(s)} aria-label="Delete">
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
</TableCell> </TableCell>
@@ -114,24 +106,24 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) {
</TableBody> </TableBody>
</Table> </Table>
<Dialog open={conflict !== null} onOpenChange={(open) => !open && setConflict(null)}> <Dialog open={pendingDelete !== null} onOpenChange={(open) => !open && setPendingDelete(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete session and recording?</DialogTitle> <DialogTitle>Delete session?</DialogTitle>
<DialogDescription> <DialogDescription>
This session has a saved recording This permanently deletes{" "}
{conflict && conflict.recordings.length > 0 <span className="font-medium text-foreground">
? ` (${conflict.recordings.map((r) => r.name).join(", ")})` {pendingDelete?.name ?? `Session #${pendingDelete?.id}`}
: ""}{" "} </span>{" "}
that still points to it. Deleting the session will also delete that recording. and all of its recorded events. This can&apos;t be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={() => setConflict(null)}> <Button variant="ghost" onClick={() => setPendingDelete(null)}>
Cancel Cancel
</Button> </Button>
<Button variant="destructive" onClick={() => void handleCascadeDelete()} disabled={deleting}> <Button variant="destructive" onClick={() => void handleDelete()} disabled={deleting}>
{deleting ? "Deleting..." : "Delete both"} {deleting ? "Deleting..." : "Delete"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,65 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export interface RecordingLibrarySummary {
count: number;
avgDurationMs: number;
totalPlayCount: number;
list: { id: number; name: string; durationMs: number; playCount: number; lastPlayedAt: number | null }[];
}
export function RecordingLibraryStats({ stats }: { stats: RecordingLibrarySummary }) {
return (
<div className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Recordings</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{stats.count}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Avg length</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">
{Math.round(stats.avgDurationMs / 1000)}s
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total plays</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{stats.totalPlayCount}</CardContent>
</Card>
</div>
{stats.list.length > 0 && (
<Card className="bp-glass">
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Recording</TableHead>
<TableHead>Plays</TableHead>
<TableHead>Last played</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.list.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell className="bp-readout">{r.playCount}</TableCell>
<TableCell className="bp-readout text-muted-foreground">
{r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
+2 -7
View File
@@ -4,7 +4,7 @@ export interface SessionsSummary {
count: number; count: number;
totalDurationMs: number; totalDurationMs: number;
avgDurationMs: number; avgDurationMs: number;
byKind: { kind: "live" | "replay"; count: number; totalDurationMs: number }[]; totalReplays: number;
durationPerDevice: { deviceId: number; displayName: string | null; bleName: string; totalActiveMs: number }[]; durationPerDevice: { deviceId: number; displayName: string | null; bleName: string; totalActiveMs: number }[];
} }
@@ -13,9 +13,6 @@ function formatHours(ms: number): string {
} }
export function SessionsSummaryCards({ summary }: { summary: SessionsSummary }) { export function SessionsSummaryCards({ summary }: { summary: SessionsSummary }) {
const liveCount = summary.byKind.find((k) => k.kind === "live")?.count ?? 0;
const replayCount = summary.byKind.find((k) => k.kind === "replay")?.count ?? 0;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
@@ -41,9 +38,7 @@ export function SessionsSummaryCards({ summary }: { summary: SessionsSummary })
</Card> </Card>
</div> </div>
<Card className="bp-glass"> <Card className="bp-glass">
<CardContent className="py-3 text-sm text-muted-foreground"> <CardContent className="py-3 text-sm text-muted-foreground">Replayed {summary.totalReplays}×</CardContent>
{liveCount} live · {replayCount} replay
</CardContent>
</Card> </Card>
{summary.durationPerDevice.length > 0 && ( {summary.durationPerDevice.length > 0 && (
<Card className="bp-glass"> <Card className="bp-glass">
+17
View File
@@ -0,0 +1,17 @@
DROP TABLE `recordings`;--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_play_sessions` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text,
`kind` text NOT NULL,
`status` text DEFAULT 'active' NOT NULL,
`started_at` integer NOT NULL,
`ended_at` integer,
`duration_ms` integer,
`notes` text
);
--> statement-breakpoint
INSERT INTO `__new_play_sessions`("id", "name", "kind", "status", "started_at", "ended_at", "duration_ms", "notes") SELECT "id", "name", "kind", "status", "started_at", "ended_at", "duration_ms", "notes" FROM `play_sessions`;--> statement-breakpoint
DROP TABLE `play_sessions`;--> statement-breakpoint
ALTER TABLE `__new_play_sessions` RENAME TO `play_sessions`;--> statement-breakpoint
PRAGMA foreign_keys=ON;
@@ -0,0 +1,4 @@
ALTER TABLE `play_sessions` ADD `description` text;--> statement-breakpoint
ALTER TABLE `play_sessions` ADD `replayed_session_id` integer REFERENCES play_sessions(id) ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE `play_sessions` ADD `play_count` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `play_sessions` ADD `last_played_at` integer;
+5
View File
@@ -0,0 +1,5 @@
ALTER TABLE `play_sessions` RENAME TO `sessions`;--> statement-breakpoint
ALTER TABLE `session_devices` RENAME COLUMN `play_session_id` TO `session_id`;--> statement-breakpoint
ALTER TABLE `session_events` RENAME COLUMN `play_session_id` TO `session_id`;--> statement-breakpoint
DROP INDEX `session_devices_play_session_id_idx`;--> statement-breakpoint
CREATE INDEX `session_devices_session_id_idx` ON `session_devices` (`session_id`);
+347
View File
@@ -0,0 +1,347 @@
{
"version": "6",
"dialect": "sqlite",
"id": "dd3f46c7-f6a2-428b-b751-982018e83803",
"prevId": "cb592704-f845-4761-b5cc-53ec7cb5d179",
"tables": {
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ble_name": {
"name": "ble_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_class": {
"name": "device_class",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"capabilities": {
"name": "capabilities",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_connected_at": {
"name": "last_connected_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"play_sessions": {
"name": "play_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ended_at": {
"name": "ended_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_devices": {
"name": "session_devices",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"play_session_id": {
"name": "play_session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slot_label": {
"name": "slot_label",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connected_at": {
"name": "connected_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"disconnected_at": {
"name": "disconnected_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"session_devices_play_session_id_idx": {
"name": "session_devices_play_session_id_idx",
"columns": [
"play_session_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_devices_play_session_id_play_sessions_id_fk": {
"name": "session_devices_play_session_id_play_sessions_id_fk",
"tableFrom": "session_devices",
"tableTo": "play_sessions",
"columnsFrom": [
"play_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"session_devices_device_id_devices_id_fk": {
"name": "session_devices_device_id_devices_id_fk",
"tableFrom": "session_devices",
"tableTo": "devices",
"columnsFrom": [
"device_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_events": {
"name": "session_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"play_session_id": {
"name": "play_session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_device_id": {
"name": "session_device_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ts_ms": {
"name": "ts_ms",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command_type": {
"name": "command_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feature_index": {
"name": "feature_index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_payload": {
"name": "raw_payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"session_events_session_ts_idx": {
"name": "session_events_session_ts_idx",
"columns": [
"play_session_id",
"ts_ms"
],
"isUnique": false
},
"session_events_session_device_idx": {
"name": "session_events_session_device_idx",
"columns": [
"session_device_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_events_play_session_id_play_sessions_id_fk": {
"name": "session_events_play_session_id_play_sessions_id_fk",
"tableFrom": "session_events",
"tableTo": "play_sessions",
"columnsFrom": [
"play_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"session_events_session_device_id_session_devices_id_fk": {
"name": "session_events_session_device_id_session_devices_id_fk",
"tableFrom": "session_events",
"tableTo": "session_devices",
"columnsFrom": [
"session_device_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+390
View File
@@ -0,0 +1,390 @@
{
"version": "6",
"dialect": "sqlite",
"id": "d2afbfe4-99cd-4898-956d-2097b26dda60",
"prevId": "dd3f46c7-f6a2-428b-b751-982018e83803",
"tables": {
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ble_name": {
"name": "ble_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_class": {
"name": "device_class",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"capabilities": {
"name": "capabilities",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_connected_at": {
"name": "last_connected_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"play_sessions": {
"name": "play_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"replayed_session_id": {
"name": "replayed_session_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ended_at": {
"name": "ended_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"play_count": {
"name": "play_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_played_at": {
"name": "last_played_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"play_sessions_replayed_session_id_play_sessions_id_fk": {
"name": "play_sessions_replayed_session_id_play_sessions_id_fk",
"tableFrom": "play_sessions",
"tableTo": "play_sessions",
"columnsFrom": [
"replayed_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_devices": {
"name": "session_devices",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"play_session_id": {
"name": "play_session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slot_label": {
"name": "slot_label",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connected_at": {
"name": "connected_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"disconnected_at": {
"name": "disconnected_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"session_devices_play_session_id_idx": {
"name": "session_devices_play_session_id_idx",
"columns": [
"play_session_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_devices_play_session_id_play_sessions_id_fk": {
"name": "session_devices_play_session_id_play_sessions_id_fk",
"tableFrom": "session_devices",
"tableTo": "play_sessions",
"columnsFrom": [
"play_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"session_devices_device_id_devices_id_fk": {
"name": "session_devices_device_id_devices_id_fk",
"tableFrom": "session_devices",
"tableTo": "devices",
"columnsFrom": [
"device_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_events": {
"name": "session_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"play_session_id": {
"name": "play_session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_device_id": {
"name": "session_device_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ts_ms": {
"name": "ts_ms",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command_type": {
"name": "command_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feature_index": {
"name": "feature_index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_payload": {
"name": "raw_payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"session_events_session_ts_idx": {
"name": "session_events_session_ts_idx",
"columns": [
"play_session_id",
"ts_ms"
],
"isUnique": false
},
"session_events_session_device_idx": {
"name": "session_events_session_device_idx",
"columns": [
"session_device_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_events_play_session_id_play_sessions_id_fk": {
"name": "session_events_play_session_id_play_sessions_id_fk",
"tableFrom": "session_events",
"tableTo": "play_sessions",
"columnsFrom": [
"play_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"session_events_session_device_id_session_devices_id_fk": {
"name": "session_events_session_device_id_session_devices_id_fk",
"tableFrom": "session_events",
"tableTo": "session_devices",
"columnsFrom": [
"session_device_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+390
View File
@@ -0,0 +1,390 @@
{
"version": "6",
"dialect": "sqlite",
"id": "3d3770ee-d054-4dc7-9ff2-c033e8d000a1",
"prevId": "d2afbfe4-99cd-4898-956d-2097b26dda60",
"tables": {
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ble_name": {
"name": "ble_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_class": {
"name": "device_class",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"capabilities": {
"name": "capabilities",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_connected_at": {
"name": "last_connected_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_devices": {
"name": "session_devices",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"device_id": {
"name": "device_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slot_label": {
"name": "slot_label",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"connected_at": {
"name": "connected_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"disconnected_at": {
"name": "disconnected_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_devices_session_id_idx": {
"name": "session_devices_session_id_idx",
"columns": [
"session_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_devices_device_id_devices_id_fk": {
"name": "session_devices_device_id_devices_id_fk",
"tableFrom": "session_devices",
"tableTo": "devices",
"columnsFrom": [
"device_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
},
"session_devices_session_id_sessions_id_fk": {
"name": "session_devices_session_id_sessions_id_fk",
"tableFrom": "session_devices",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_events": {
"name": "session_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"session_device_id": {
"name": "session_device_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ts_ms": {
"name": "ts_ms",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command_type": {
"name": "command_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feature_index": {
"name": "feature_index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_payload": {
"name": "raw_payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_events_session_ts_idx": {
"name": "session_events_session_ts_idx",
"columns": [
"session_id",
"ts_ms"
],
"isUnique": false
},
"session_events_session_device_idx": {
"name": "session_events_session_device_idx",
"columns": [
"session_device_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_events_session_device_id_session_devices_id_fk": {
"name": "session_events_session_device_id_session_devices_id_fk",
"tableFrom": "session_events",
"tableTo": "session_devices",
"columnsFrom": [
"session_device_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"session_events_session_id_sessions_id_fk": {
"name": "session_events_session_id_sessions_id_fk",
"tableFrom": "session_events",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"replayed_session_id": {
"name": "replayed_session_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ended_at": {
"name": "ended_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"play_count": {
"name": "play_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_played_at": {
"name": "last_played_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_replayed_session_id_sessions_id_fk": {
"name": "sessions_replayed_session_id_sessions_id_fk",
"tableFrom": "sessions",
"tableTo": "sessions",
"columnsFrom": [
"replayed_session_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+21
View File
@@ -8,6 +8,27 @@
"when": 1787617808883, "when": 1787617808883,
"tag": "0000_bouncy_anita_blake", "tag": "0000_bouncy_anita_blake",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1787856296915,
"tag": "0001_drop_recordings",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1787856317550,
"tag": "0002_add_session_replay_fields",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1787950004000,
"tag": "0003_rename_sessions",
"breakpoints": true
} }
] ]
} }
+3 -4
View File
@@ -1,5 +1,4 @@
import type { RecordingDeviceSlot } from "@/lib/db/schema"; import type { ConnectedDeviceInfo, ReplayDeviceSlot } from "./types";
import type { ConnectedDeviceInfo } from "./types";
export interface DeviceRemapEntry { export interface DeviceRemapEntry {
slotLabel: string; slotLabel: string;
@@ -8,14 +7,14 @@ export interface DeviceRemapEntry {
} }
/** /**
* Best-effort name match from a recording's saved device slots to the * Best-effort name match from a session's device slots to the
* currently-connected devices. Web Bluetooth exposes no stable hardware id * currently-connected devices. Web Bluetooth exposes no stable hardware id
* across sessions, so this is inherently approximate: two devices sharing an * across sessions, so this is inherently approximate: two devices sharing an
* identical advertised name are indistinguishable and must be disambiguated * identical advertised name are indistinguishable and must be disambiguated
* manually in the remap UI - this isn't a bug, it's a hard BLE limitation. * manually in the remap UI - this isn't a bug, it's a hard BLE limitation.
*/ */
export function autoMapDeviceSlots( export function autoMapDeviceSlots(
slots: RecordingDeviceSlot[], slots: ReplayDeviceSlot[],
connectedDevices: ConnectedDeviceInfo[], connectedDevices: ConnectedDeviceInfo[],
): DeviceRemapEntry[] { ): DeviceRemapEntry[] {
const usedIndexes = new Set<number>(); const usedIndexes = new Set<number>();
+12 -13
View File
@@ -13,21 +13,20 @@ interface ApiEvent {
/** /**
* Buffers every dispatched command (live or replay) and flushes it in * Buffers every dispatched command (live or replay) and flushes it in
* batches to the play-session's events endpoint, so a dragged slider never * batches to the session's events endpoint, so a dragged slider never
* fires one HTTP request per tick. Every command is always recorded here * fires one HTTP request per tick. Every command is always recorded here
* regardless of whether the session is later saved as a named recording - * regardless of whether the session is later named - any completed session
* "recording" is a save decision made after the fact, not a separate * can be replayed directly, there's no separate "save as recording" step.
* capture pipeline (see lib/db/queries/recordings.ts).
*/ */
class EventBuffer { class EventBuffer {
private buffer: CommandEvent[] = []; private buffer: CommandEvent[] = [];
private playSessionId: number | null = null; private sessionId: number | null = null;
private sessionStartedAt = 0; private sessionStartedAt = 0;
private sessionDeviceIdByDeviceIndex = new Map<number, number>(); private sessionDeviceIdByDeviceIndex = new Map<number, number>();
private timer: ReturnType<typeof setInterval> | null = null; private timer: ReturnType<typeof setInterval> | null = null;
start(playSessionId: number, sessionStartedAt: number): void { start(sessionId: number, sessionStartedAt: number): void {
this.playSessionId = playSessionId; this.sessionId = sessionId;
this.sessionStartedAt = sessionStartedAt; this.sessionStartedAt = sessionStartedAt;
this.sessionDeviceIdByDeviceIndex = new Map(); this.sessionDeviceIdByDeviceIndex = new Map();
this.buffer = []; this.buffer = [];
@@ -46,16 +45,16 @@ class EventBuffer {
} }
record(event: Omit<CommandEvent, "tsMs"> & { tsMs?: number }): void { record(event: Omit<CommandEvent, "tsMs"> & { tsMs?: number }): void {
if (this.playSessionId === null) return; if (this.sessionId === null) return;
const tsMs = event.tsMs ?? Date.now() - this.sessionStartedAt; const tsMs = event.tsMs ?? Date.now() - this.sessionStartedAt;
this.buffer.push({ ...event, tsMs }); this.buffer.push({ ...event, tsMs });
} }
async flush(): Promise<void> { async flush(): Promise<void> {
if (this.buffer.length === 0 || this.playSessionId === null) return; if (this.buffer.length === 0 || this.sessionId === null) return;
const events = this.drain(); const events = this.drain();
try { try {
await fetch(`/api/play-sessions/${this.playSessionId}/events`, { await fetch(`/api/sessions/${this.sessionId}/events`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events }), body: JSON.stringify({ events }),
@@ -71,7 +70,7 @@ class EventBuffer {
void this.flush(); void this.flush();
if (this.timer) clearInterval(this.timer); if (this.timer) clearInterval(this.timer);
this.timer = null; this.timer = null;
this.playSessionId = null; this.sessionId = null;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.removeEventListener("beforeunload", this.flushBeacon); window.removeEventListener("beforeunload", this.flushBeacon);
document.removeEventListener("visibilitychange", this.onVisibilityChange); document.removeEventListener("visibilitychange", this.onVisibilityChange);
@@ -83,10 +82,10 @@ class EventBuffer {
}; };
private flushBeacon = (): void => { private flushBeacon = (): void => {
if (this.buffer.length === 0 || this.playSessionId === null || typeof navigator === "undefined") return; if (this.buffer.length === 0 || this.sessionId === null || typeof navigator === "undefined") return;
const events = this.drain(); const events = this.drain();
navigator.sendBeacon( navigator.sendBeacon(
`/api/play-sessions/${this.playSessionId}/events`, `/api/sessions/${this.sessionId}/events`,
new Blob([JSON.stringify({ events })], { type: "application/json" }), new Blob([JSON.stringify({ events })], { type: "application/json" }),
); );
}; };
+11 -19
View File
@@ -1,9 +1,8 @@
import { getDevice } from "./client"; import { getDevice } from "./client";
import { buildOutputCommand, findFeature, type ButtplugRuntime } from "./commands"; import { buildOutputCommand, findFeature, type ButtplugRuntime } from "./commands";
import { eventBuffer } from "./event-buffer";
import type { ActuatorInfo, CommandEvent } from "./types"; import type { ActuatorInfo, CommandEvent } from "./types";
export interface RecordingEventRow { export interface SessionEventRow {
tsMs: number; tsMs: number;
commandType: CommandEvent["commandType"]; commandType: CommandEvent["commandType"];
featureIndex: number; featureIndex: number;
@@ -13,13 +12,14 @@ export interface RecordingEventRow {
} }
export interface PlayerOptions { export interface PlayerOptions {
events: RecordingEventRow[]; events: SessionEventRow[];
/** The recording's actual duration (recordings.durationMs from the DB) - this is the source /** The source session's actual duration (sessions.duration_ms from the DB) - this is
* of truth for playback length, NOT the last event's timestamp: a recording can run for a * the source of truth for playback length, NOT the last event's timestamp: a session can run
* while after its last command (e.g. the user stopped the toy but let the session continue), * for a while after its last command (e.g. the user stopped the toy but let the session
* so deriving duration from events would end playback early and misreport it as "finished". */ * continue), so deriving duration from events would end playback early and misreport it as
* "finished". */
durationMs: number; durationMs: number;
/** Recording's session_device_id -> currently-connected device index, from the remap step. */ /** Source session's session_device_id -> currently-connected device index, from the remap step. */
sessionDeviceIdToDeviceIndex: Map<number, number>; sessionDeviceIdToDeviceIndex: Map<number, number>;
actuatorsByDeviceIndex: Map<number, ActuatorInfo[]>; actuatorsByDeviceIndex: Map<number, ActuatorInfo[]>;
runtime: ButtplugRuntime; runtime: ButtplugRuntime;
@@ -30,11 +30,11 @@ export interface PlayerOptions {
} }
/** /**
* Replays a recording's events against the currently-connected devices, * Replays a session's events against the currently-connected devices,
* using performance.now()-relative scheduling (not wall-clock Date.now()) * using performance.now()-relative scheduling (not wall-clock Date.now())
* so long sessions don't accumulate drift from setTimeout jitter. * so long sessions don't accumulate drift from setTimeout jitter.
*/ */
export class RecordingPlayer { export class SessionPlayer {
private readonly durationMs: number; private readonly durationMs: number;
private startedAtPerf = 0; private startedAtPerf = 0;
private pausedAtMs = 0; private pausedAtMs = 0;
@@ -110,7 +110,7 @@ export class RecordingPlayer {
}, 200); }, 200);
} }
private async dispatch(event: RecordingEventRow): Promise<void> { private async dispatch(event: SessionEventRow): Promise<void> {
const deviceIndex = this.options.sessionDeviceIdToDeviceIndex.get(event.sessionDeviceId); const deviceIndex = this.options.sessionDeviceIdToDeviceIndex.get(event.sessionDeviceId);
if (deviceIndex === undefined) return; if (deviceIndex === undefined) return;
@@ -130,14 +130,6 @@ export class RecordingPlayer {
const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined); const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined);
await feature.runOutput(cmd); await feature.runOutput(cmd);
} }
eventBuffer.record({
deviceIndex,
commandType: event.commandType,
featureIndex: event.featureIndex,
value: event.value,
durationMs: event.durationMs ?? undefined,
});
} catch (err) { } catch (err) {
this.options.onError?.(err instanceof Error ? err.message : String(err)); this.options.onError?.(err instanceof Error ? err.message : String(err));
} }
+17
View File
@@ -4,6 +4,8 @@
* rows) never has to import the client library directly. * rows) never has to import the client library directly.
*/ */
import type { DeviceCapabilities } from "@/lib/db/schema";
export type NormalizedOutputType = "vibrate" | "rotate" | "linear"; export type NormalizedOutputType = "vibrate" | "rotate" | "linear";
export type CommandType = NormalizedOutputType | "stop"; export type CommandType = NormalizedOutputType | "stop";
@@ -32,3 +34,18 @@ export interface CommandEvent {
value: number; value: number;
durationMs?: number; durationMs?: number;
} }
/**
* A device that took part in a session, as needed to remap it onto a
* currently-connected device for replay. Computed live from that session's
* session_devices/devices rows - not a frozen snapshot.
*/
export interface ReplayDeviceSlot {
slotLabel: string;
recordedBleName: string;
deviceClass: string | null;
capabilities: DeviceCapabilities | null;
/** The session_devices.id this slot was captured from - lets the replay UI
* map a chosen live device back to this slot's session_events. */
sourceSessionDeviceId: number;
}
-133
View File
@@ -1,133 +0,0 @@
import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { playSessions, sessionDevices, devices, recordings } from "@/lib/db/schema";
import { upsertDeviceByBleName } from "./devices";
import { incrementPlayCount } from "./recordings";
import type { DeviceCapabilities } from "@/lib/db/schema";
import { PAGE_SIZE, type Page } from "@/lib/pagination";
export interface StartSessionDeviceInput {
slotLabel: string;
bleName: string;
deviceClass?: string | null;
capabilities?: DeviceCapabilities;
}
export interface StartSessionInput {
kind: "live" | "replay";
replayedRecordingId?: number;
name?: string;
devices: StartSessionDeviceInput[];
}
export async function startPlaySession(input: StartSessionInput) {
const now = Date.now();
const [session] = await db
.insert(playSessions)
.values({
kind: input.kind,
replayedRecordingId: input.replayedRecordingId,
name: input.name,
status: "active",
startedAt: now,
})
.returning();
const sessionDeviceRows = [];
for (const d of input.devices) {
const device = await upsertDeviceByBleName({
bleName: d.bleName,
deviceClass: d.deviceClass,
capabilities: d.capabilities,
});
const [sessionDevice] = await db
.insert(sessionDevices)
.values({
playSessionId: session.id,
deviceId: device.id,
slotLabel: d.slotLabel,
connectedAt: now,
})
.returning();
sessionDeviceRows.push(sessionDevice);
}
if (input.kind === "replay" && input.replayedRecordingId) {
await incrementPlayCount(input.replayedRecordingId, now);
}
return { session, sessionDevices: sessionDeviceRows };
}
export async function endPlaySession(
id: number,
input: { status: "completed" | "aborted" },
) {
const [existing] = await db.select().from(playSessions).where(eq(playSessions.id, id));
if (!existing) return undefined;
const endedAt = Date.now();
const durationMs = endedAt - existing.startedAt;
await db
.update(sessionDevices)
.set({ disconnectedAt: endedAt })
.where(eq(sessionDevices.playSessionId, id));
const [updated] = await db
.update(playSessions)
.set({ endedAt, durationMs, status: input.status })
.where(eq(playSessions.id, id))
.returning();
return updated;
}
export async function listPlaySessions() {
return db.select().from(playSessions).orderBy(playSessions.startedAt);
}
export async function listPlaySessionsPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof playSessions.$inferSelect>> {
const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(playSessions);
const items = await db
.select()
.from(playSessions)
.orderBy(desc(playSessions.startedAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
return { items, page, pageSize, total: count };
}
/** Lightweight name-only lookup for page titles - avoids getPlaySessionDetail's device join. */
export async function getPlaySessionName(id: number): Promise<string | null | undefined> {
const [row] = await db.select({ name: playSessions.name }).from(playSessions).where(eq(playSessions.id, id));
return row?.name;
}
export async function getPlaySessionDetail(id: number) {
const [session] = await db.select().from(playSessions).where(eq(playSessions.id, id));
if (!session) return undefined;
const sessionDeviceRows = await db
.select({
id: sessionDevices.id,
slotLabel: sessionDevices.slotLabel,
connectedAt: sessionDevices.connectedAt,
disconnectedAt: sessionDevices.disconnectedAt,
deviceId: devices.id,
deviceDisplayName: devices.displayName,
deviceBleName: devices.bleName,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.where(eq(sessionDevices.playSessionId, id));
return { session, devices: sessionDeviceRows };
}
export async function deletePlaySession(id: number, options?: { cascade?: boolean }) {
if (options?.cascade) {
await db.delete(recordings).where(eq(recordings.sourcePlaySessionId, id));
}
await db.delete(playSessions).where(eq(playSessions.id, id));
}
-111
View File
@@ -1,111 +0,0 @@
import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { recordings, sessionDevices, devices, playSessions, type RecordingDeviceSlot } from "@/lib/db/schema";
import { getEventsForSession } from "./session-events";
import { PAGE_SIZE, type Page } from "@/lib/pagination";
/**
* A recording is a thin pointer over an already-captured play_session, not a
* duplicated event pipeline: every live command is always persisted to
* session_events (stats need it regardless of whether the user saves a
* recording), so "saving a recording" just snapshots the session's device
* slots and points a new row at it. This keeps the replay-loader query and
* the stats-timeline query reading the exact same rows, with zero risk of
* live/recorded data drifting apart.
*/
export async function createRecording(input: {
sourcePlaySessionId: number;
name: string;
description?: string;
}) {
const [session] = await db.select().from(playSessions).where(eq(playSessions.id, input.sourcePlaySessionId));
if (!session) throw new Error("source play session not found");
const sessionDeviceRows = await db
.select({
sessionDeviceId: sessionDevices.id,
slotLabel: sessionDevices.slotLabel,
bleName: devices.bleName,
deviceClass: devices.deviceClass,
capabilities: devices.capabilities,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.where(eq(sessionDevices.playSessionId, input.sourcePlaySessionId));
const deviceSlots: RecordingDeviceSlot[] = sessionDeviceRows.map((row) => ({
slotLabel: row.slotLabel,
recordedBleName: row.bleName,
deviceClass: row.deviceClass,
capabilities: row.capabilities,
sourceSessionDeviceId: row.sessionDeviceId,
}));
const [recording] = await db
.insert(recordings)
.values({
sourcePlaySessionId: input.sourcePlaySessionId,
name: input.name,
description: input.description,
createdAt: Date.now(),
durationMs: session.durationMs ?? 0,
deviceSlots,
playCount: 0,
})
.returning();
return recording;
}
export async function listRecordings() {
return db.select().from(recordings).orderBy(desc(recordings.createdAt));
}
export async function listRecordingsPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof recordings.$inferSelect>> {
const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(recordings);
const items = await db
.select()
.from(recordings)
.orderBy(desc(recordings.createdAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
return { items, page, pageSize, total: count };
}
export async function getRecordingsForSession(playSessionId: number) {
return db
.select({ id: recordings.id, name: recordings.name })
.from(recordings)
.where(eq(recordings.sourcePlaySessionId, playSessionId));
}
/** Lightweight name-only lookup for page titles - avoids getRecording's event-table join. */
export async function getRecordingName(id: number): Promise<string | undefined> {
const [row] = await db.select({ name: recordings.name }).from(recordings).where(eq(recordings.id, id));
return row?.name;
}
export async function getRecording(id: number) {
const [recording] = await db.select().from(recordings).where(eq(recordings.id, id));
if (!recording) return undefined;
const events = await getEventsForSession(recording.sourcePlaySessionId);
return { recording, events };
}
export async function renameRecording(id: number, input: { name?: string; description?: string }) {
const [updated] = await db.update(recordings).set(input).where(eq(recordings.id, id)).returning();
return updated;
}
export async function deleteRecording(id: number) {
await db.delete(recordings).where(eq(recordings.id, id));
}
export async function incrementPlayCount(id: number, playedAt: number): Promise<void> {
const [existing] = await db.select().from(recordings).where(eq(recordings.id, id));
if (!existing) return;
await db
.update(recordings)
.set({ playCount: existing.playCount + 1, lastPlayedAt: playedAt })
.where(eq(recordings.id, id));
}
+4 -4
View File
@@ -11,14 +11,14 @@ export interface IncomingEvent {
durationMs?: number; durationMs?: number;
} }
export async function insertEvents(playSessionId: number, events: IncomingEvent[]): Promise<void> { export async function insertEvents(sessionId: number, events: IncomingEvent[]): Promise<void> {
if (events.length === 0) return; if (events.length === 0) return;
const insertMany = sqlite.transaction((rows: IncomingEvent[]) => { const insertMany = sqlite.transaction((rows: IncomingEvent[]) => {
for (const row of rows) { for (const row of rows) {
db.insert(sessionEvents) db.insert(sessionEvents)
.values({ .values({
playSessionId, sessionId,
sessionDeviceId: row.sessionDeviceId, sessionDeviceId: row.sessionDeviceId,
tsMs: row.tsMs, tsMs: row.tsMs,
commandType: row.commandType, commandType: row.commandType,
@@ -33,10 +33,10 @@ export async function insertEvents(playSessionId: number, events: IncomingEvent[
insertMany(events); insertMany(events);
} }
export async function getEventsForSession(playSessionId: number) { export async function getEventsForSession(sessionId: number) {
return db return db
.select() .select()
.from(sessionEvents) .from(sessionEvents)
.where(eq(sessionEvents.playSessionId, playSessionId)) .where(eq(sessionEvents.sessionId, sessionId))
.orderBy(asc(sessionEvents.tsMs)); .orderBy(asc(sessionEvents.tsMs));
} }
+178
View File
@@ -0,0 +1,178 @@
import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { sessions, sessionDevices, devices } from "@/lib/db/schema";
import { upsertDeviceByBleName } from "./devices";
import { getEventsForSession } from "./session-events";
import type { DeviceCapabilities } from "@/lib/db/schema";
import type { ReplayDeviceSlot } from "@/lib/buttplug/types";
import { PAGE_SIZE, type Page } from "@/lib/pagination";
export interface StartSessionDeviceInput {
slotLabel: string;
bleName: string;
deviceClass?: string | null;
capabilities?: DeviceCapabilities;
}
export interface StartSessionInput {
name?: string;
devices: StartSessionDeviceInput[];
}
/**
* Starts a live control session. Replaying an existing session does NOT go
* through here - it just plays back the source session's already-recorded
* events against newly-mapped devices (see components/sessions/ReplayPlayer)
* and bumps the source's playCount/lastPlayedAt (incrementSessionPlayCount)
* without creating a session or duplicate events of its own.
*/
export async function startSession(input: StartSessionInput) {
const now = Date.now();
const [session] = await db
.insert(sessions)
.values({
kind: "live",
name: input.name,
status: "active",
startedAt: now,
})
.returning();
const sessionDeviceRows = [];
for (const d of input.devices) {
const device = await upsertDeviceByBleName({
bleName: d.bleName,
deviceClass: d.deviceClass,
capabilities: d.capabilities,
});
const [sessionDevice] = await db
.insert(sessionDevices)
.values({
sessionId: session.id,
deviceId: device.id,
slotLabel: d.slotLabel,
connectedAt: now,
})
.returning();
sessionDeviceRows.push(sessionDevice);
}
return { session, sessionDevices: sessionDeviceRows };
}
export async function endSession(
id: number,
input: { status: "completed" | "aborted" },
) {
const [existing] = await db.select().from(sessions).where(eq(sessions.id, id));
if (!existing) return undefined;
const endedAt = Date.now();
const durationMs = endedAt - existing.startedAt;
await db
.update(sessionDevices)
.set({ disconnectedAt: endedAt })
.where(eq(sessionDevices.sessionId, id));
const [updated] = await db
.update(sessions)
.set({ endedAt, durationMs, status: input.status })
.where(eq(sessions.id, id))
.returning();
return updated;
}
export async function listSessions() {
return db.select().from(sessions).orderBy(sessions.startedAt);
}
export async function listSessionsPage(page: number, pageSize = PAGE_SIZE): Promise<Page<typeof sessions.$inferSelect>> {
const [{ count }] = await db.select({ count: sql<number>`count(*)` }).from(sessions);
const items = await db
.select()
.from(sessions)
.orderBy(desc(sessions.startedAt))
.limit(pageSize)
.offset((page - 1) * pageSize);
return { items, page, pageSize, total: count };
}
/** Lightweight name-only lookup for page titles - avoids getSessionDetail's device join. */
export async function getSessionName(id: number): Promise<string | null | undefined> {
const [row] = await db.select({ name: sessions.name }).from(sessions).where(eq(sessions.id, id));
return row?.name;
}
export async function getSessionDetail(id: number) {
const [session] = await db.select().from(sessions).where(eq(sessions.id, id));
if (!session) return undefined;
const sessionDeviceRows = await db
.select({
id: sessionDevices.id,
slotLabel: sessionDevices.slotLabel,
connectedAt: sessionDevices.connectedAt,
disconnectedAt: sessionDevices.disconnectedAt,
deviceId: devices.id,
deviceDisplayName: devices.displayName,
deviceBleName: devices.bleName,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.where(eq(sessionDevices.sessionId, id));
const replayedFromName = session.replayedSessionId ? await getSessionName(session.replayedSessionId) : undefined;
return { session, devices: sessionDeviceRows, replayedFromName };
}
/** Snapshot of a session's devices, shaped for the replay device-remap UI - computed live
* from session_devices/devices, not a frozen copy (see ReplayDeviceSlot). */
export async function getSessionForReplay(id: number) {
const [session] = await db.select().from(sessions).where(eq(sessions.id, id));
if (!session) return undefined;
const deviceSlotRows = await db
.select({
sessionDeviceId: sessionDevices.id,
slotLabel: sessionDevices.slotLabel,
bleName: devices.bleName,
deviceClass: devices.deviceClass,
capabilities: devices.capabilities,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.where(eq(sessionDevices.sessionId, id));
const deviceSlots: ReplayDeviceSlot[] = deviceSlotRows.map((row) => ({
slotLabel: row.slotLabel,
recordedBleName: row.bleName,
deviceClass: row.deviceClass,
capabilities: row.capabilities,
sourceSessionDeviceId: row.sessionDeviceId,
}));
const events = await getEventsForSession(id);
return { session, deviceSlots, events };
}
export async function renameSession(id: number, input: { name?: string; description?: string }) {
const [updated] = await db.update(sessions).set(input).where(eq(sessions.id, id)).returning();
return updated;
}
export async function incrementSessionPlayCount(id: number, playedAt: number): Promise<void> {
const [existing] = await db.select().from(sessions).where(eq(sessions.id, id));
if (!existing) return;
await db
.update(sessions)
.set({ playCount: existing.playCount + 1, lastPlayedAt: playedAt })
.where(eq(sessions.id, id));
}
export async function deleteSession(id: number) {
await db.delete(sessions).where(eq(sessions.id, id));
}
+12 -42
View File
@@ -1,26 +1,19 @@
import { sql, eq, and, ne } from "drizzle-orm"; import { sql, eq, and, ne } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { playSessions, sessionDevices, sessionEvents, devices, recordings } from "@/lib/db/schema"; import { sessions, sessionDevices, sessionEvents, devices } from "@/lib/db/schema";
export async function getSessionsSummary() { export async function getSessionsSummary() {
const [totals] = await db const [totals] = await db
.select({ .select({
count: sql<number>`count(*)`, count: sql<number>`count(*)`,
totalDurationMs: sql<number>`coalesce(sum(${playSessions.durationMs}), 0)`, totalDurationMs: sql<number>`coalesce(sum(${sessions.durationMs}), 0)`,
avgDurationMs: sql<number>`coalesce(avg(${playSessions.durationMs}), 0)`, avgDurationMs: sql<number>`coalesce(avg(${sessions.durationMs}), 0)`,
// How many times sessions have been replayed - replaying itself creates no
// session of its own, it just bumps the source session's playCount.
totalReplays: sql<number>`coalesce(sum(${sessions.playCount}), 0)`,
}) })
.from(playSessions) .from(sessions)
.where(eq(playSessions.status, "completed")); .where(eq(sessions.status, "completed"));
const byKind = await db
.select({
kind: playSessions.kind,
count: sql<number>`count(*)`,
totalDurationMs: sql<number>`coalesce(sum(${playSessions.durationMs}), 0)`,
})
.from(playSessions)
.where(eq(playSessions.status, "completed"))
.groupBy(playSessions.kind);
const durationPerDevice = await db const durationPerDevice = await db
.select({ .select({
@@ -33,10 +26,10 @@ export async function getSessionsSummary() {
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id)) .innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.groupBy(devices.id); .groupBy(devices.id);
return { ...totals, byKind, durationPerDevice }; return { ...totals, durationPerDevice };
} }
export async function getSessionTimeline(playSessionId: number, bucketMs = 1000) { export async function getSessionTimeline(sessionId: number, bucketMs = 1000) {
// Reuse the same expression object (not a `sql`bucket`` alias reference) // Reuse the same expression object (not a `sql`bucket`` alias reference)
// in groupBy/orderBy - drizzle doesn't emit a literal `AS bucket` that // in groupBy/orderBy - drizzle doesn't emit a literal `AS bucket` that
// SQLite's GROUP BY/ORDER BY could resolve a bare "bucket" identifier // SQLite's GROUP BY/ORDER BY could resolve a bare "bucket" identifier
@@ -53,7 +46,7 @@ export async function getSessionTimeline(playSessionId: number, bucketMs = 1000)
}) })
.from(sessionEvents) .from(sessionEvents)
.innerJoin(sessionDevices, eq(sessionEvents.sessionDeviceId, sessionDevices.id)) .innerJoin(sessionDevices, eq(sessionEvents.sessionDeviceId, sessionDevices.id))
.where(and(eq(sessionEvents.playSessionId, playSessionId), ne(sessionEvents.commandType, "stop"))) .where(and(eq(sessionEvents.sessionId, sessionId), ne(sessionEvents.commandType, "stop")))
.groupBy(bucket, sessionEvents.sessionDeviceId) .groupBy(bucket, sessionEvents.sessionDeviceId)
.orderBy(bucket); .orderBy(bucket);
} }
@@ -64,7 +57,7 @@ export async function getDeviceUsageStats() {
deviceId: devices.id, deviceId: devices.id,
displayName: devices.displayName, displayName: devices.displayName,
bleName: devices.bleName, bleName: devices.bleName,
sessionCount: sql<number>`count(distinct ${sessionDevices.playSessionId})`, sessionCount: sql<number>`count(distinct ${sessionDevices.sessionId})`,
totalActiveMs: sql<number>`coalesce(sum(coalesce(${sessionDevices.disconnectedAt}, ${sessionDevices.connectedAt}) - ${sessionDevices.connectedAt}), 0)`, totalActiveMs: sql<number>`coalesce(sum(coalesce(${sessionDevices.disconnectedAt}, ${sessionDevices.connectedAt}) - ${sessionDevices.connectedAt}), 0)`,
lastUsedAt: sql<number | null>`max(${sessionDevices.connectedAt})`, lastUsedAt: sql<number | null>`max(${sessionDevices.connectedAt})`,
}) })
@@ -84,26 +77,3 @@ export async function getDeviceCommandCounts() {
.leftJoin(sessionEvents, eq(sessionEvents.sessionDeviceId, sessionDevices.id)) .leftJoin(sessionEvents, eq(sessionEvents.sessionDeviceId, sessionDevices.id))
.groupBy(devices.id); .groupBy(devices.id);
} }
export async function getRecordingLibraryStats() {
const [totals] = await db
.select({
count: sql<number>`count(*)`,
avgDurationMs: sql<number>`coalesce(avg(${recordings.durationMs}), 0)`,
totalPlayCount: sql<number>`coalesce(sum(${recordings.playCount}), 0)`,
})
.from(recordings);
const list = await db
.select({
id: recordings.id,
name: recordings.name,
durationMs: recordings.durationMs,
playCount: recordings.playCount,
lastPlayedAt: recordings.lastPlayedAt,
})
.from(recordings)
.orderBy(sql`${recordings.playCount} desc`);
return { ...totals, list };
}
+15 -34
View File
@@ -21,11 +21,13 @@ export const devices = sqliteTable("devices", {
lastConnectedAt: integer("last_connected_at"), lastConnectedAt: integer("last_connected_at"),
}); });
export const playSessions = sqliteTable("play_sessions", { export const sessions = sqliteTable("sessions", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name"), name: text("name"),
description: text("description"),
kind: text("kind", { enum: ["live", "replay"] }).notNull(), kind: text("kind", { enum: ["live", "replay"] }).notNull(),
replayedRecordingId: integer("replayed_recording_id").references((): AnySQLiteColumn => recordings.id, { // Self-referencing: which session's events this session replayed, if any.
replayedSessionId: integer("replayed_session_id").references((): AnySQLiteColumn => sessions.id, {
onDelete: "set null", onDelete: "set null",
}), }),
status: text("status", { enum: ["active", "completed", "aborted"] }).notNull().default("active"), status: text("status", { enum: ["active", "completed", "aborted"] }).notNull().default("active"),
@@ -33,15 +35,20 @@ export const playSessions = sqliteTable("play_sessions", {
endedAt: integer("ended_at"), endedAt: integer("ended_at"),
durationMs: integer("duration_ms"), durationMs: integer("duration_ms"),
notes: text("notes"), notes: text("notes"),
// How many times (and when) this session's events have been replayed -
// any completed session can be a replay source, there's no separate
// "saved recording" concept anymore.
playCount: integer("play_count").notNull().default(0),
lastPlayedAt: integer("last_played_at"),
}); });
export const sessionDevices = sqliteTable( export const sessionDevices = sqliteTable(
"session_devices", "session_devices",
{ {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
playSessionId: integer("play_session_id") sessionId: integer("session_id")
.notNull() .notNull()
.references(() => playSessions.id, { onDelete: "cascade" }), .references(() => sessions.id, { onDelete: "cascade" }),
deviceId: integer("device_id") deviceId: integer("device_id")
.notNull() .notNull()
.references(() => devices.id, { onDelete: "restrict" }), .references(() => devices.id, { onDelete: "restrict" }),
@@ -49,16 +56,16 @@ export const sessionDevices = sqliteTable(
connectedAt: integer("connected_at").notNull(), connectedAt: integer("connected_at").notNull(),
disconnectedAt: integer("disconnected_at"), disconnectedAt: integer("disconnected_at"),
}, },
(table) => [index("session_devices_play_session_id_idx").on(table.playSessionId)], (table) => [index("session_devices_session_id_idx").on(table.sessionId)],
); );
export const sessionEvents = sqliteTable( export const sessionEvents = sqliteTable(
"session_events", "session_events",
{ {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
playSessionId: integer("play_session_id") sessionId: integer("session_id")
.notNull() .notNull()
.references(() => playSessions.id, { onDelete: "cascade" }), .references(() => sessions.id, { onDelete: "cascade" }),
sessionDeviceId: integer("session_device_id") sessionDeviceId: integer("session_device_id")
.notNull() .notNull()
.references(() => sessionDevices.id, { onDelete: "cascade" }), .references(() => sessionDevices.id, { onDelete: "cascade" }),
@@ -72,33 +79,7 @@ export const sessionEvents = sqliteTable(
rawPayload: text("raw_payload", { mode: "json" }), rawPayload: text("raw_payload", { mode: "json" }),
}, },
(table) => [ (table) => [
index("session_events_session_ts_idx").on(table.playSessionId, table.tsMs), index("session_events_session_ts_idx").on(table.sessionId, table.tsMs),
index("session_events_session_device_idx").on(table.sessionDeviceId), index("session_events_session_device_idx").on(table.sessionDeviceId),
], ],
); );
export interface RecordingDeviceSlot {
slotLabel: string;
recordedBleName: string;
deviceClass: string | null;
capabilities: DeviceCapabilities | null;
/** The original session_devices.id this slot was captured from - lets the
* replay UI map a chosen live device back to this slot's session_events. */
sourceSessionDeviceId: number;
}
export const recordings = sqliteTable("recordings", {
id: integer("id").primaryKey({ autoIncrement: true }),
// A recording is a thin pointer over an already-captured play_session, not
// a duplicated event pipeline - see lib/db/queries/recordings.ts for why.
sourcePlaySessionId: integer("source_play_session_id")
.notNull()
.references(() => playSessions.id, { onDelete: "restrict" }),
name: text("name").notNull(),
description: text("description"),
createdAt: integer("created_at").notNull(),
durationMs: integer("duration_ms").notNull(),
deviceSlots: text("device_slots", { mode: "json" }).$type<RecordingDeviceSlot[]>().notNull(),
playCount: integer("play_count").notNull().default(0),
lastPlayedAt: integer("last_played_at"),
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sexy", "name": "sexy",
"version": "0.5.0", "version": "0.6.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",