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

Welcome back

- 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.

- - - - - Details - - -
- Duration: - {formatDuration(recording.durationMs)} -
-
- Plays: - {recording.playCount} -
-
- Created: - {new Date(recording.createdAt).toLocaleString()} -
-
- Last played: - {recording.lastPlayedAt ? new Date(recording.lastPlayedAt).toLocaleString() : "Never"} -
-
-
- - - - Devices in this recording - - - {recording.deviceSlots.map((slot) => ( -
- {slot.slotLabel} ({slot.recordedBleName}) -
- ))} -
-
- - ); -} diff --git a/app/(app)/recordings/page.tsx b/app/(app)/recordings/page.tsx deleted file mode 100644 index d1d8965..0000000 --- a/app/(app)/recordings/page.tsx +++ /dev/null @@ -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 ( -
-
-

Recordings

-

Saved sessions you can replay against connected devices.

-
- - - Library - - - - - - -
- ); -} diff --git a/app/(app)/sessions/[id]/page.tsx b/app/(app)/sessions/[id]/page.tsx index bd8d341..a6ef620 100644 --- a/app/(app)/sessions/[id]/page.tsx +++ b/app/(app)/sessions/[id]/page.tsx @@ -1,15 +1,18 @@ +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 { 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 { Play } from "lucide-react"; export const dynamic = "force-dynamic"; export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params; - const name = await getPlaySessionName(Number(id)); + const name = await getSessionName(Number(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 }> }) { const { id } = await params; 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(); + const canReplay = detail.session.status === "completed" && detail.session.durationMs !== null; + return (
-
-

{detail.session.name ?? `Session #${detail.session.id}`}

-

- {detail.session.kind} · {detail.session.status} · {formatDuration(detail.session.durationMs)} -

+
+
+

{detail.session.name ?? `Session #${detail.session.id}`}

+

+ {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()})`} + + )} +

+ {detail.session.description && ( +

{detail.session.description}

+ )} +
+ {canReplay && ( + + )}
diff --git a/app/(app)/recordings/[id]/replay/page.tsx b/app/(app)/sessions/[id]/replay/page.tsx similarity index 66% rename from app/(app)/recordings/[id]/replay/page.tsx rename to app/(app)/sessions/[id]/replay/page.tsx index f2f37fc..46eca40 100644 --- a/app/(app)/recordings/[id]/replay/page.tsx +++ b/app/(app)/sessions/[id]/replay/page.tsx @@ -1,10 +1,10 @@ import type { Metadata } from "next"; -import { ReplayPlayerLoader } from "@/components/recordings/ReplayPlayerLoader"; -import { getRecordingName } from "@/lib/db/queries/recordings"; +import { ReplayPlayerLoader } from "@/components/sessions/ReplayPlayerLoader"; +import { getSessionName } from "@/lib/db/queries/sessions"; export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params; - const name = await getRecordingName(Number(id)); + const name = await getSessionName(Number(id)); return { title: name ? `Replay ${name}` : "Replay" }; } @@ -13,7 +13,7 @@ export default async function ReplayPage({ params }: { params: Promise<{ id: str return (

Replay

- +
); } diff --git a/app/(app)/sessions/page.tsx b/app/(app)/sessions/page.tsx index 881ad51..982b48d 100644 --- a/app/(app)/sessions/page.tsx +++ b/app/(app)/sessions/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { SessionsTable } from "@/components/sessions/SessionsTable"; 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"; export const dynamic = "force-dynamic"; @@ -14,7 +14,7 @@ export default async function SessionsPage({ searchParams: Promise<{ page?: string }>; }) { const page = parsePage((await searchParams).page); - const { items: sessions, pageSize, total } = await listPlaySessionsPage(page); + const { items: sessions, pageSize, total } = await listSessionsPage(page); return (
diff --git a/app/(app)/stats/page.tsx b/app/(app)/stats/page.tsx index a1654a0..42ae19c 100644 --- a/app/(app)/stats/page.tsx +++ b/app/(app)/stats/page.tsx @@ -2,18 +2,16 @@ import type { Metadata } from "next"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { SessionsSummaryCards } from "@/components/stats/SessionsSummaryCards"; import { DeviceUsageTable } from "@/components/stats/DeviceUsageTable"; -import { RecordingLibraryStats } from "@/components/stats/RecordingLibraryStats"; -import { getDeviceCommandCounts, getDeviceUsageStats, getRecordingLibraryStats, getSessionsSummary } from "@/lib/db/queries/stats"; +import { getDeviceCommandCounts, getDeviceUsageStats, getSessionsSummary } from "@/lib/db/queries/stats"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Stats" }; export default async function StatsPage() { - const [sessionsSummary, deviceUsage, commandCounts, recordingStats] = await Promise.all([ + const [sessionsSummary, deviceUsage, commandCounts] = await Promise.all([ getSessionsSummary(), getDeviceUsageStats(), getDeviceCommandCounts(), - getRecordingLibraryStats(), ]); const commandCountByDevice = new Map(commandCounts.map((c) => [c.deviceId, c.commandCount])); @@ -23,14 +21,13 @@ export default async function StatsPage() {

Stats

-

Usage across sessions, devices, and your recording library.

+

Usage across sessions and devices.

Sessions Devices - Recordings @@ -38,9 +35,6 @@ export default async function StatsPage() { - - -
); diff --git a/app/api/play-sessions/[id]/route.ts b/app/api/play-sessions/[id]/route.ts deleted file mode 100644 index ae244f7..0000000 --- a/app/api/play-sessions/[id]/route.ts +++ /dev/null @@ -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; - } - }, -); diff --git a/app/api/recordings/[id]/route.ts b/app/api/recordings/[id]/route.ts deleted file mode 100644 index 4de99a5..0000000 --- a/app/api/recordings/[id]/route.ts +++ /dev/null @@ -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 }); - }, -); diff --git a/app/api/recordings/route.ts b/app/api/recordings/route.ts deleted file mode 100644 index 0c398e2..0000000 --- a/app/api/recordings/route.ts +++ /dev/null @@ -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 }); -}); diff --git a/app/api/play-sessions/[id]/events/route.ts b/app/api/sessions/[id]/events/route.ts similarity index 96% rename from app/api/play-sessions/[id]/events/route.ts rename to app/api/sessions/[id]/events/route.ts index 63db4b8..e405200 100644 --- a/app/api/play-sessions/[id]/events/route.ts +++ b/app/api/sessions/[id]/events/route.ts @@ -15,7 +15,7 @@ const eventSchema = z.object({ const bodySchema = z.object({ events: z.array(eventSchema).max(2000) }); export const POST = withRouteLogging( - "play-sessions.events.append", + "sessions.events.append", async (req: Request, { params }: { params: Promise<{ id: string }> }) => { const { id } = await params; const parsed = bodySchema.safeParse(await req.json().catch(() => null)); diff --git a/app/api/sessions/[id]/replay/route.ts b/app/api/sessions/[id]/replay/route.ts new file mode 100644 index 0000000..aabb841 --- /dev/null +++ b/app/api/sessions/[id]/replay/route.ts @@ -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 }); + }, +); diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts new file mode 100644 index 0000000..36c33bb --- /dev/null +++ b/app/api/sessions/[id]/route.ts @@ -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 }); + }, +); diff --git a/app/api/play-sessions/route.ts b/app/api/sessions/route.ts similarity index 60% rename from app/api/play-sessions/route.ts rename to app/api/sessions/route.ts index 8281a77..3c5dfa3 100644 --- a/app/api/play-sessions/route.ts +++ b/app/api/sessions/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; 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"; const deviceInputSchema = z.object({ @@ -11,22 +11,20 @@ const deviceInputSchema = z.object({ }); const bodySchema = z.object({ - kind: z.enum(["live", "replay"]), - replayedRecordingId: z.number().int().positive().optional(), name: z.string().min(1).optional(), devices: z.array(deviceInputSchema).min(1), }); -export const GET = withRouteLogging("play-sessions.list", async () => { - const rows = await listPlaySessions(); - return NextResponse.json({ playSessions: rows }); +export const GET = withRouteLogging("sessions.list", async () => { + const rows = await listSessions(); + 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)); if (!parsed.success) { 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 }); }); diff --git a/app/api/stats/recordings/route.ts b/app/api/stats/recordings/route.ts deleted file mode 100644 index 80ab381..0000000 --- a/app/api/stats/recordings/route.ts +++ /dev/null @@ -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); -}); diff --git a/components/control/ButtplugConsole.tsx b/components/control/ButtplugConsole.tsx index 82a933a..aad57b6 100644 --- a/components/control/ButtplugConsole.tsx +++ b/components/control/ButtplugConsole.tsx @@ -20,10 +20,10 @@ import type { ConnectedDeviceInfo } from "@/lib/buttplug/types"; import { DeviceScanPanel } from "./DeviceScanPanel"; import { DeviceCard } from "./DeviceCard"; import { RecordControls } from "./RecordControls"; -import { SaveRecordingDialog } from "@/components/recordings/SaveRecordingDialog"; +import { NameSessionDialog } from "./NameSessionDialog"; import { Card, CardContent } from "@/components/ui/card"; -interface ActivePlaySession { +interface ActiveSession { id: number; startedAt: number; sessionDeviceIdByDeviceIndex: Map; @@ -49,7 +49,7 @@ export function ButtplugConsole() { const setRecording = useButtplugStore((s) => s.setRecording); const storeError = useButtplugStore((s) => s.error); - const [activeSession, setActiveSession] = useState(null); + const [activeSession, setActiveSession] = useState(null); const [elapsedMs, setElapsedMs] = useState(0); const [sessionBusy, setSessionBusy] = useState(false); const [savePromptSessionId, setSavePromptSessionId] = useState(null); @@ -105,14 +105,13 @@ export function ButtplugConsole() { try { const deviceList = Object.values(devices); const body = { - kind: "live" as const, devices: deviceList.map((d) => ({ slotLabel: d.displayName ?? d.name, bleName: d.name, 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", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -151,7 +150,7 @@ export function ButtplugConsole() { setSessionBusy(true); eventBuffer.stop(); setRecording(false); - await fetch(`/api/play-sessions/${activeSession.id}`, { + await fetch(`/api/sessions/${activeSession.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "completed" }), @@ -270,8 +269,8 @@ export function ButtplugConsole() {
)} - setSavePromptSessionId(null)} onSaved={() => setSavePromptSessionId(null)} /> diff --git a/components/recordings/SaveRecordingDialog.tsx b/components/control/NameSessionDialog.tsx similarity index 61% rename from components/recordings/SaveRecordingDialog.tsx rename to components/control/NameSessionDialog.tsx index 8f6fbbd..0777e2e 100644 --- a/components/recordings/SaveRecordingDialog.tsx +++ b/components/control/NameSessionDialog.tsx @@ -14,50 +14,50 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; -interface SaveRecordingDialogProps { - playSessionId: number | null; +interface NameSessionDialogProps { + sessionId: number | null; onClose: () => void; onSaved: () => void; } -export function SaveRecordingDialog({ playSessionId, onClose, onSaved }: SaveRecordingDialogProps) { +export function NameSessionDialog({ sessionId, onClose, onSaved }: NameSessionDialogProps) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [saving, setSaving] = useState(false); async function handleSave() { - if (!playSessionId || name.trim().length === 0) return; + if (!sessionId || name.trim().length === 0) return; setSaving(true); - const res = await fetch("/api/recordings", { - method: "POST", + const res = await fetch(`/api/sessions/${sessionId}`, { + method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sourcePlaySessionId: playSessionId, name: name.trim(), description }), + body: JSON.stringify({ name: name.trim(), description }), }); setSaving(false); if (res.ok) { - toast.success("Recording saved"); + toast.success("Session named"); onSaved(); } else { - toast.error("Could not save recording"); + toast.error("Could not name session"); } } return ( - !open && onClose()}> + !open && onClose()}> - Save session as a recording + Name this session Give it a name so you can find and replay it later.
- - setName(e.target.value)} autoFocus /> + + setName(e.target.value)} autoFocus />
- + setDescription(e.target.value)} /> @@ -68,7 +68,7 @@ export function SaveRecordingDialog({ playSessionId, onClose, onSaved }: SaveRec Skip diff --git a/components/control/RecordControls.tsx b/components/control/RecordControls.tsx index ab2ff15..beb20be 100644 --- a/components/control/RecordControls.tsx +++ b/components/control/RecordControls.tsx @@ -20,7 +20,7 @@ export function RecordControls({ active, elapsedLabel, disabled, busy, onStart, Session live · {elapsedLabel} -
diff --git a/components/layout/Breadcrumbs.tsx b/components/layout/Breadcrumbs.tsx index 779592a..82668d8 100644 --- a/components/layout/Breadcrumbs.tsx +++ b/components/layout/Breadcrumbs.tsx @@ -6,7 +6,6 @@ import { ChevronRight, House } from "lucide-react"; const SECTION_LABELS: Record = { control: "Control", - recordings: "Recordings", sessions: "Sessions", stats: "Stats", devices: "Devices", diff --git a/components/layout/NavBar.tsx b/components/layout/NavBar.tsx index 9e09b65..0deb2df 100644 --- a/components/layout/NavBar.tsx +++ b/components/layout/NavBar.tsx @@ -14,7 +14,6 @@ import { cn } from "@/lib/utils"; const LINKS = [ { href: "/", label: "Dashboard" }, { href: "/control", label: "Control" }, - { href: "/recordings", label: "Recordings" }, { href: "/sessions", label: "Sessions" }, { href: "/stats", label: "Stats" }, { href: "/devices", label: "Devices" }, diff --git a/components/recordings/RecordingsTable.tsx b/components/recordings/RecordingsTable.tsx deleted file mode 100644 index 500c889..0000000 --- a/components/recordings/RecordingsTable.tsx +++ /dev/null @@ -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 ( -

- No recordings yet - start a session on the Control page and save it when you're done. -

- ); - } - - return ( - - - - Name - Duration - Plays - Last played - Actions - - - - {recordings.map((r) => ( - - - - {r.name} - - - {formatDuration(r.durationMs)} - - - {r.playCount} - - - - {r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"} - - - - - - - ))} - -
- ); -} diff --git a/components/recordings/DeviceRemapDialog.tsx b/components/sessions/DeviceRemapDialog.tsx similarity index 95% rename from components/recordings/DeviceRemapDialog.tsx rename to components/sessions/DeviceRemapDialog.tsx index ab4e779..98ca064 100644 --- a/components/recordings/DeviceRemapDialog.tsx +++ b/components/sessions/DeviceRemapDialog.tsx @@ -12,12 +12,11 @@ import { import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { autoMapDeviceSlots } from "@/lib/buttplug/device-remap"; -import type { RecordingDeviceSlot } from "@/lib/db/schema"; -import type { ConnectedDeviceInfo } from "@/lib/buttplug/types"; +import type { ReplayDeviceSlot, ConnectedDeviceInfo } from "@/lib/buttplug/types"; interface DeviceRemapDialogProps { open: boolean; - deviceSlots: RecordingDeviceSlot[]; + deviceSlots: ReplayDeviceSlot[]; connectedDevices: ConnectedDeviceInfo[]; onCancel: () => void; onConfirm: (mapping: Map) => void; diff --git a/components/recordings/ReplayPlayer.tsx b/components/sessions/ReplayPlayer.tsx similarity index 72% rename from components/recordings/ReplayPlayer.tsx rename to components/sessions/ReplayPlayer.tsx index b738784..23cee3f 100644 --- a/components/recordings/ReplayPlayer.tsx +++ b/components/sessions/ReplayPlayer.tsx @@ -8,21 +8,19 @@ import { Slider } from "@/components/ui/slider"; import { DeviceScanPanel } from "@/components/control/DeviceScanPanel"; import { DeviceRemapDialog } from "./DeviceRemapDialog"; import { disconnectAll, getButtplugClientHandle, getDevice, startScanning, stopScanning } from "@/lib/buttplug/client"; -import { eventBuffer } from "@/lib/buttplug/event-buffer"; -import { RecordingPlayer, type RecordingEventRow } from "@/lib/buttplug/player"; +import { SessionPlayer, type SessionEventRow } from "@/lib/buttplug/player"; import { useButtplugStore } from "@/lib/buttplug/store"; -import type { ActuatorInfo } from "@/lib/buttplug/types"; -import type { RecordingDeviceSlot } from "@/lib/db/schema"; +import type { ActuatorInfo, ReplayDeviceSlot } from "@/lib/buttplug/types"; import { Pause, Play } from "lucide-react"; -interface RecordingResponse { - recording: { +interface ReplayDataResponse { + session: { id: number; - name: string; + name: string | null; durationMs: number; - deviceSlots: RecordingDeviceSlot[]; }; - events: RecordingEventRow[]; + deviceSlots: ReplayDeviceSlot[]; + events: SessionEventRow[]; } 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")}`; } -export function ReplayPlayer({ recordingId }: { recordingId: number }) { +export function ReplayPlayer({ sessionId }: { sessionId: number }) { const scanning = useButtplugStore((s) => s.scanning); const devices = useButtplugStore((s) => s.devices); const setActuatorValue = useButtplugStore((s) => s.setActuatorValue); const removeDevice = useButtplugStore((s) => s.removeDevice); const connectedDevices = useMemo(() => Object.values(devices), [devices]); - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [showRemap, setShowRemap] = useState(false); const [starting, setStarting] = useState(false); - const [player, setPlayer] = useState(null); + const [player, setPlayer] = useState(null); const [playing, setPlaying] = useState(false); const [elapsedMs, setElapsedMs] = useState(0); const [replayTargets, setReplayTargets] = useState<{ slotLabel: string; deviceName: string }[]>([]); const [replayDeviceIndexes, setReplayDeviceIndexes] = useState([]); - const [activeSessionId, setActiveSessionId] = useState(null); useEffect(() => { - fetch(`/api/recordings/${recordingId}`) + fetch(`/api/sessions/${sessionId}/replay`) .then((r) => r.json()) .then(setData) - .catch(() => toast.error("Could not load recording")); - }, [recordingId]); + .catch(() => toast.error("Could not load session")); + }, [sessionId]); useEffect(() => { return () => { @@ -76,41 +73,21 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) { setStarting(true); 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 deviceIndex = mapping.get(slot.sourceSessionDeviceId)!; const device = connectedDevices.find((d) => d.index === deviceIndex)!; return { slotLabel: slot.slotLabel, deviceName: device.displayName ?? device.name }; }); - const body = { - kind: "replay" as const, - replayedRecordingId: data.recording.id, - devices: orderedSlots.map((slot, i) => ({ slotLabel: slot.slotLabel, bleName: targets[i].deviceName })), - }; + // Replaying creates no session or events of its own - it just plays this + // session's already-recorded events back onto the mapped devices, and + // marks it as replayed (playCount/lastPlayedAt). + void fetch(`/api/sessions/${data.session.id}/replay`, { method: "POST" }).catch(() => {}); - const res = await fetch("/api/play-sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - 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(); - 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); - } - }); + // `mapping` is already keyed by the source session's session_device id + // (see DeviceRemapDialog), exactly what SessionPlayer needs. + const sessionDeviceIdToDeviceIndex = mapping; const actuatorsByDeviceIndex = new Map( connectedDevices.map((d) => [d.index, d.actuators]), @@ -129,28 +106,21 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) { ); const { runtime } = await getButtplugClientHandle(); - const instance = new RecordingPlayer({ + const instance = new SessionPlayer({ events: data.events, - durationMs: data.recording.durationMs, + durationMs: data.session.durationMs, sessionDeviceIdToDeviceIndex, actuatorsByDeviceIndex, runtime, onProgress: (elapsed) => setElapsedMs(elapsed), onError: (message) => toast.error(`Replay command failed: ${message}`), - onComplete: async () => { + onComplete: () => { 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"); }, }); setReplayTargets(targets); setReplayDeviceIndexes([...deviceIndexes]); - setActiveSessionId(result.session.id); setPlayer(instance); instance.play(); setPlaying(true); @@ -162,20 +132,20 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) { } if (!data) { - return

Loading recording...

; + return

Loading session...

; } return (
- {data.recording.name} + {data.session.name ?? `Session #${data.session.id}`} {!player ? ( <>

- Connect the devices you want to replay onto, then match them to the recording's + Connect the devices you want to replay onto, then match them to the session's original device slots.

@@ -211,12 +181,12 @@ export function ReplayPlayer({ recordingId }: { recordingId: number }) { )} player.seek(v)} />
- {formatTime(elapsedMs)} / {formatTime(data.recording.durationMs)} + {formatTime(elapsedMs)} / {formatTime(data.session.durationMs)}
+ )} + @@ -114,24 +106,24 @@ export function SessionsTable({ sessions }: { sessions: SessionRow[] }) { - !open && setConflict(null)}> + !open && setPendingDelete(null)}> - Delete session and recording? + Delete session? - This session has a saved recording - {conflict && conflict.recordings.length > 0 - ? ` (${conflict.recordings.map((r) => r.name).join(", ")})` - : ""}{" "} - that still points to it. Deleting the session will also delete that recording. + This permanently deletes{" "} + + {pendingDelete?.name ?? `Session #${pendingDelete?.id}`} + {" "} + and all of its recorded events. This can't be undone. - - diff --git a/components/stats/RecordingLibraryStats.tsx b/components/stats/RecordingLibraryStats.tsx deleted file mode 100644 index 6976712..0000000 --- a/components/stats/RecordingLibraryStats.tsx +++ /dev/null @@ -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 ( -
-
- - - Recordings - - {stats.count} - - - - Avg length - - - {Math.round(stats.avgDurationMs / 1000)}s - - - - - Total plays - - {stats.totalPlayCount} - -
- - {stats.list.length > 0 && ( - - - - - - Recording - Plays - Last played - - - - {stats.list.map((r) => ( - - {r.name} - {r.playCount} - - {r.lastPlayedAt ? new Date(r.lastPlayedAt).toLocaleString() : "Never"} - - - ))} - -
-
-
- )} -
- ); -} diff --git a/components/stats/SessionsSummaryCards.tsx b/components/stats/SessionsSummaryCards.tsx index 29f05c1..e755ca8 100644 --- a/components/stats/SessionsSummaryCards.tsx +++ b/components/stats/SessionsSummaryCards.tsx @@ -4,7 +4,7 @@ export interface SessionsSummary { count: number; totalDurationMs: number; avgDurationMs: number; - byKind: { kind: "live" | "replay"; count: number; totalDurationMs: number }[]; + totalReplays: 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 }) { - const liveCount = summary.byKind.find((k) => k.kind === "live")?.count ?? 0; - const replayCount = summary.byKind.find((k) => k.kind === "replay")?.count ?? 0; - return (
@@ -41,9 +38,7 @@ export function SessionsSummaryCards({ summary }: { summary: SessionsSummary })
- - {liveCount} live · {replayCount} replay - + Replayed {summary.totalReplays}× {summary.durationPerDevice.length > 0 && ( diff --git a/drizzle/0001_drop_recordings.sql b/drizzle/0001_drop_recordings.sql new file mode 100644 index 0000000..431c5a1 --- /dev/null +++ b/drizzle/0001_drop_recordings.sql @@ -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; \ No newline at end of file diff --git a/drizzle/0002_add_session_replay_fields.sql b/drizzle/0002_add_session_replay_fields.sql new file mode 100644 index 0000000..825bcdd --- /dev/null +++ b/drizzle/0002_add_session_replay_fields.sql @@ -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; \ No newline at end of file diff --git a/drizzle/0003_rename_sessions.sql b/drizzle/0003_rename_sessions.sql new file mode 100644 index 0000000..12926a6 --- /dev/null +++ b/drizzle/0003_rename_sessions.sql @@ -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`); diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..c3da3f6 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..51d07ea --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..fc49293 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -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": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 44326e1..538a07e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,27 @@ "when": 1787617808883, "tag": "0000_bouncy_anita_blake", "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 } ] } \ No newline at end of file diff --git a/lib/buttplug/device-remap.ts b/lib/buttplug/device-remap.ts index 00b192d..7ae41b6 100644 --- a/lib/buttplug/device-remap.ts +++ b/lib/buttplug/device-remap.ts @@ -1,5 +1,4 @@ -import type { RecordingDeviceSlot } from "@/lib/db/schema"; -import type { ConnectedDeviceInfo } from "./types"; +import type { ConnectedDeviceInfo, ReplayDeviceSlot } from "./types"; export interface DeviceRemapEntry { 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 * across sessions, so this is inherently approximate: two devices sharing an * 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. */ export function autoMapDeviceSlots( - slots: RecordingDeviceSlot[], + slots: ReplayDeviceSlot[], connectedDevices: ConnectedDeviceInfo[], ): DeviceRemapEntry[] { const usedIndexes = new Set(); diff --git a/lib/buttplug/event-buffer.ts b/lib/buttplug/event-buffer.ts index 1c2a797..389f871 100644 --- a/lib/buttplug/event-buffer.ts +++ b/lib/buttplug/event-buffer.ts @@ -13,21 +13,20 @@ interface ApiEvent { /** * 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 - * regardless of whether the session is later saved as a named recording - - * "recording" is a save decision made after the fact, not a separate - * capture pipeline (see lib/db/queries/recordings.ts). + * regardless of whether the session is later named - any completed session + * can be replayed directly, there's no separate "save as recording" step. */ class EventBuffer { private buffer: CommandEvent[] = []; - private playSessionId: number | null = null; + private sessionId: number | null = null; private sessionStartedAt = 0; private sessionDeviceIdByDeviceIndex = new Map(); private timer: ReturnType | null = null; - start(playSessionId: number, sessionStartedAt: number): void { - this.playSessionId = playSessionId; + start(sessionId: number, sessionStartedAt: number): void { + this.sessionId = sessionId; this.sessionStartedAt = sessionStartedAt; this.sessionDeviceIdByDeviceIndex = new Map(); this.buffer = []; @@ -46,16 +45,16 @@ class EventBuffer { } record(event: Omit & { tsMs?: number }): void { - if (this.playSessionId === null) return; + if (this.sessionId === null) return; const tsMs = event.tsMs ?? Date.now() - this.sessionStartedAt; this.buffer.push({ ...event, tsMs }); } async flush(): Promise { - if (this.buffer.length === 0 || this.playSessionId === null) return; + if (this.buffer.length === 0 || this.sessionId === null) return; const events = this.drain(); try { - await fetch(`/api/play-sessions/${this.playSessionId}/events`, { + await fetch(`/api/sessions/${this.sessionId}/events`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ events }), @@ -71,7 +70,7 @@ class EventBuffer { void this.flush(); if (this.timer) clearInterval(this.timer); this.timer = null; - this.playSessionId = null; + this.sessionId = null; if (typeof window !== "undefined") { window.removeEventListener("beforeunload", this.flushBeacon); document.removeEventListener("visibilitychange", this.onVisibilityChange); @@ -83,10 +82,10 @@ class EventBuffer { }; 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(); navigator.sendBeacon( - `/api/play-sessions/${this.playSessionId}/events`, + `/api/sessions/${this.sessionId}/events`, new Blob([JSON.stringify({ events })], { type: "application/json" }), ); }; diff --git a/lib/buttplug/player.ts b/lib/buttplug/player.ts index cf17163..e1d89c7 100644 --- a/lib/buttplug/player.ts +++ b/lib/buttplug/player.ts @@ -1,9 +1,8 @@ import { getDevice } from "./client"; import { buildOutputCommand, findFeature, type ButtplugRuntime } from "./commands"; -import { eventBuffer } from "./event-buffer"; import type { ActuatorInfo, CommandEvent } from "./types"; -export interface RecordingEventRow { +export interface SessionEventRow { tsMs: number; commandType: CommandEvent["commandType"]; featureIndex: number; @@ -13,13 +12,14 @@ export interface RecordingEventRow { } export interface PlayerOptions { - events: RecordingEventRow[]; - /** The recording's actual duration (recordings.durationMs from the DB) - this is the source - * of truth for playback length, NOT the last event's timestamp: a recording can run for a - * while after its last command (e.g. the user stopped the toy but let the session continue), - * so deriving duration from events would end playback early and misreport it as "finished". */ + events: SessionEventRow[]; + /** The source session's actual duration (sessions.duration_ms from the DB) - this is + * the source of truth for playback length, NOT the last event's timestamp: a session can run + * for a while after its last command (e.g. the user stopped the toy but let the session + * continue), so deriving duration from events would end playback early and misreport it as + * "finished". */ 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; actuatorsByDeviceIndex: Map; 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()) * so long sessions don't accumulate drift from setTimeout jitter. */ -export class RecordingPlayer { +export class SessionPlayer { private readonly durationMs: number; private startedAtPerf = 0; private pausedAtMs = 0; @@ -110,7 +110,7 @@ export class RecordingPlayer { }, 200); } - private async dispatch(event: RecordingEventRow): Promise { + private async dispatch(event: SessionEventRow): Promise { const deviceIndex = this.options.sessionDeviceIdToDeviceIndex.get(event.sessionDeviceId); if (deviceIndex === undefined) return; @@ -130,14 +130,6 @@ export class RecordingPlayer { const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined); await feature.runOutput(cmd); } - - eventBuffer.record({ - deviceIndex, - commandType: event.commandType, - featureIndex: event.featureIndex, - value: event.value, - durationMs: event.durationMs ?? undefined, - }); } catch (err) { this.options.onError?.(err instanceof Error ? err.message : String(err)); } diff --git a/lib/buttplug/types.ts b/lib/buttplug/types.ts index 0fb816a..33299bd 100644 --- a/lib/buttplug/types.ts +++ b/lib/buttplug/types.ts @@ -4,6 +4,8 @@ * rows) never has to import the client library directly. */ +import type { DeviceCapabilities } from "@/lib/db/schema"; + export type NormalizedOutputType = "vibrate" | "rotate" | "linear"; export type CommandType = NormalizedOutputType | "stop"; @@ -32,3 +34,18 @@ export interface CommandEvent { value: 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; +} diff --git a/lib/db/queries/play-sessions.ts b/lib/db/queries/play-sessions.ts deleted file mode 100644 index 72cb4e2..0000000 --- a/lib/db/queries/play-sessions.ts +++ /dev/null @@ -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> { - const [{ count }] = await db.select({ count: sql`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 { - 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)); -} diff --git a/lib/db/queries/recordings.ts b/lib/db/queries/recordings.ts deleted file mode 100644 index c6cbc99..0000000 --- a/lib/db/queries/recordings.ts +++ /dev/null @@ -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> { - const [{ count }] = await db.select({ count: sql`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 { - 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 { - 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)); -} diff --git a/lib/db/queries/session-events.ts b/lib/db/queries/session-events.ts index 8c543a5..3279e71 100644 --- a/lib/db/queries/session-events.ts +++ b/lib/db/queries/session-events.ts @@ -11,14 +11,14 @@ export interface IncomingEvent { durationMs?: number; } -export async function insertEvents(playSessionId: number, events: IncomingEvent[]): Promise { +export async function insertEvents(sessionId: number, events: IncomingEvent[]): Promise { if (events.length === 0) return; const insertMany = sqlite.transaction((rows: IncomingEvent[]) => { for (const row of rows) { db.insert(sessionEvents) .values({ - playSessionId, + sessionId, sessionDeviceId: row.sessionDeviceId, tsMs: row.tsMs, commandType: row.commandType, @@ -33,10 +33,10 @@ export async function insertEvents(playSessionId: number, events: IncomingEvent[ insertMany(events); } -export async function getEventsForSession(playSessionId: number) { +export async function getEventsForSession(sessionId: number) { return db .select() .from(sessionEvents) - .where(eq(sessionEvents.playSessionId, playSessionId)) + .where(eq(sessionEvents.sessionId, sessionId)) .orderBy(asc(sessionEvents.tsMs)); } diff --git a/lib/db/queries/sessions.ts b/lib/db/queries/sessions.ts new file mode 100644 index 0000000..635c13c --- /dev/null +++ b/lib/db/queries/sessions.ts @@ -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> { + const [{ count }] = await db.select({ count: sql`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 { + 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 { + 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)); +} diff --git a/lib/db/queries/stats.ts b/lib/db/queries/stats.ts index 2ee40b7..ef36fa9 100644 --- a/lib/db/queries/stats.ts +++ b/lib/db/queries/stats.ts @@ -1,26 +1,19 @@ import { sql, eq, and, ne } from "drizzle-orm"; 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() { const [totals] = await db .select({ count: sql`count(*)`, - totalDurationMs: sql`coalesce(sum(${playSessions.durationMs}), 0)`, - avgDurationMs: sql`coalesce(avg(${playSessions.durationMs}), 0)`, + totalDurationMs: sql`coalesce(sum(${sessions.durationMs}), 0)`, + avgDurationMs: sql`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`coalesce(sum(${sessions.playCount}), 0)`, }) - .from(playSessions) - .where(eq(playSessions.status, "completed")); - - const byKind = await db - .select({ - kind: playSessions.kind, - count: sql`count(*)`, - totalDurationMs: sql`coalesce(sum(${playSessions.durationMs}), 0)`, - }) - .from(playSessions) - .where(eq(playSessions.status, "completed")) - .groupBy(playSessions.kind); + .from(sessions) + .where(eq(sessions.status, "completed")); const durationPerDevice = await db .select({ @@ -33,10 +26,10 @@ export async function getSessionsSummary() { .innerJoin(devices, eq(sessionDevices.deviceId, 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) // in groupBy/orderBy - drizzle doesn't emit a literal `AS bucket` that // 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) .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) .orderBy(bucket); } @@ -64,7 +57,7 @@ export async function getDeviceUsageStats() { deviceId: devices.id, displayName: devices.displayName, bleName: devices.bleName, - sessionCount: sql`count(distinct ${sessionDevices.playSessionId})`, + sessionCount: sql`count(distinct ${sessionDevices.sessionId})`, totalActiveMs: sql`coalesce(sum(coalesce(${sessionDevices.disconnectedAt}, ${sessionDevices.connectedAt}) - ${sessionDevices.connectedAt}), 0)`, lastUsedAt: sql`max(${sessionDevices.connectedAt})`, }) @@ -84,26 +77,3 @@ export async function getDeviceCommandCounts() { .leftJoin(sessionEvents, eq(sessionEvents.sessionDeviceId, sessionDevices.id)) .groupBy(devices.id); } - -export async function getRecordingLibraryStats() { - const [totals] = await db - .select({ - count: sql`count(*)`, - avgDurationMs: sql`coalesce(avg(${recordings.durationMs}), 0)`, - totalPlayCount: sql`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 }; -} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 6105ab6..1de18a8 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -21,11 +21,13 @@ export const devices = sqliteTable("devices", { lastConnectedAt: integer("last_connected_at"), }); -export const playSessions = sqliteTable("play_sessions", { +export const sessions = sqliteTable("sessions", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name"), + description: text("description"), 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", }), status: text("status", { enum: ["active", "completed", "aborted"] }).notNull().default("active"), @@ -33,15 +35,20 @@ export const playSessions = sqliteTable("play_sessions", { endedAt: integer("ended_at"), durationMs: integer("duration_ms"), 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( "session_devices", { id: integer("id").primaryKey({ autoIncrement: true }), - playSessionId: integer("play_session_id") + sessionId: integer("session_id") .notNull() - .references(() => playSessions.id, { onDelete: "cascade" }), + .references(() => sessions.id, { onDelete: "cascade" }), deviceId: integer("device_id") .notNull() .references(() => devices.id, { onDelete: "restrict" }), @@ -49,16 +56,16 @@ export const sessionDevices = sqliteTable( connectedAt: integer("connected_at").notNull(), 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( "session_events", { id: integer("id").primaryKey({ autoIncrement: true }), - playSessionId: integer("play_session_id") + sessionId: integer("session_id") .notNull() - .references(() => playSessions.id, { onDelete: "cascade" }), + .references(() => sessions.id, { onDelete: "cascade" }), sessionDeviceId: integer("session_device_id") .notNull() .references(() => sessionDevices.id, { onDelete: "cascade" }), @@ -72,33 +79,7 @@ export const sessionEvents = sqliteTable( rawPayload: text("raw_payload", { mode: "json" }), }, (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), ], ); - -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().notNull(), - playCount: integer("play_count").notNull().default(0), - lastPlayedAt: integer("last_played_at"), -}); diff --git a/package.json b/package.json index 8df9d79..942bc6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sexy", - "version": "0.5.0", + "version": "0.6.0", "private": true, "scripts": { "dev": "next dev",