Architecture

How the pieces fit together

Three packages in a pnpm workspace monorepo, one mpv process, one Unix socket. That's the whole system.

The workspace

mpv-web-control/
  packages/
    contract/    ← shared TypeScript types (API shapes)
  apps/
    server/      ← Hono backend
    client/      ← React SPA (TanStack Router + Query)
    doc_site/    ← this docs site (TanStack Start)

The contract package is the glue. Both the server and client import their types from it — request shapes, response shapes, the works. When you change an API endpoint, you update the contract package, rebuild it, and both sides pick up the new types. TypeScript catches mismatches at build time instead of runtime.

Server (Hono)

The backend is a Hono app running on Node.js. It does three things:

  • File system API — reads MUSIC_ROOT and returns folder contents as JSON. Validates that every path stays inside the root.
  • mpv IPC bridge — opens a Unix domain socket to mpv's JSON IPC interface. Translates HTTP requests into mpv commands and returns the results.
  • Playlist storage — reads and writes JSON files in PLAYLISTS_DIR. No database, just the file system.

In production mode, it also serves the built React SPA as static files. During development, Vite handles that separately.

Client (React + TanStack)

The frontend is a single-page app built with React, TanStack Router, and TanStack Query. Router handles navigation between the browser view, the player, and the playlists page. Query handles data fetching, caching, and background refreshes.

When you browse folders, the client fetches from /api/browse. When you hit play, it POSTs to /api/player/play. The API responses are typed from the contract package, so the frontend always knows the shape of the data it's getting back.

The mpv connection

mpv runs as a separate process with --no-video --idle=yes. The server spawns it if it's not already running, then connects to its JSON IPC socket. Every player action (play, pause, seek, volume change) becomes a JSON message sent over that socket.

The socket lives at /tmp/mpv-web-control.sock by default. If mpv dies, the server catches the disconnect and tries to reconnect. You can also run your own mpv instance with --input-ipc-server=/tmp/mpv-web-control.sock and the server will find it.

Why this shape

The file-based playlist storage and the lack of a database are deliberate choices. This runs on a Raspberry Pi. SD cards die. Databases corrupt. A folder of JSON files survives almost anything — you can back it up with cp, edit playlists in a text editor, and recover from a dead card by just copying the directory.

The contract package exists because TypeScript's structural typing only gets you so far. When you have a backend and a frontend sharing the same API surface, explicit shared types save you from the "I changed the server but forgot the client" class of bugs. It's three extra seconds of build time for a lot fewer 2 AM debugging sessions.