experiments of a tiny cytube-like player with yt-dlp
0

Configure Feed

Select the types of activity you want to include in your feed.

docs: readme cleanup slop

karitham (Jun 1, 2026, 1:09 AM +0200) f1a813da fdf1166d

+5 -194
+5 -194
README.md
··· 22 22 23 23 ### Configuration 24 24 25 - | Flag | Env Variable | Default | Description | 26 - |------|-------------|---------|-------------| 27 - | `--http-addr` | `MOQBOX_HTTP_ADDR` | `0.0.0.0:3000` | HTTP listen address | 28 - | `--cache-dir` | `MOQBOX_CACHE_DIR` | `/tmp/moqbox` | yt-dlp download dir | 29 - | `--db-path` | `MOQBOX_DB_PATH` | `moqbox.db` | SQLite database file | 25 + | Flag | Env Variable | Default | Description | 26 + | ------------- | ------------------ | -------------- | -------------------- | 27 + | `--http-addr` | `MOQBOX_HTTP_ADDR` | `0.0.0.0:3000` | HTTP listen address | 28 + | `--cache-dir` | `MOQBOX_CACHE_DIR` | `/tmp/moqbox` | yt-dlp download dir | 29 + | `--db-path` | `MOQBOX_DB_PATH` | `moqbox.db` | SQLite database file | 30 30 31 31 ## Architecture 32 32 ··· 46 46 Actor -->|publish| WS[WebSocket Clients] 47 47 ``` 48 48 49 - **Key difference from v1:** MoQ transport was overengineered for social viewing 50 - (~1000 lines of varint/group/object framing). Replaced with HTTP Range file 51 - serving + wall-clock sync + simple JSON WebSocket. ~300ms drift is acceptable 52 - for "check this part" viewing. 53 - 54 - ### Impure-Pure-Impure Sandwich 55 - 56 - All mutations follow the same pattern: 57 - 58 - 1. **Gather** — `cmd = self.rx.recv().await` (receive command) 59 - 2. **Transition** — `effects = self.state.transition(&event, now)` — pure, deterministic, returns `Effect`s 60 - 3. **Commit** — `self.store.persist(&effects).await` + execute actor effects 61 - 62 - `state.rs` is purely deterministic: takes plain values, returns `Vec<Effect>`. 63 - No Context, no DB, no IO. Fully testable. 64 - 65 - ### Async download 66 - 67 - The ingest HTTP handler returns immediately after queuing a URL. A spawned 68 - task runs yt-dlp to download the media file. When done, it sends the result 69 - back to the actor which updates the DB and pushes resolved metadata to clients. 70 - 71 - ```mermaid 72 - sequenceDiagram 73 - participant Client 74 - participant HTTP as HTTP Handler 75 - participant Actor as Room Actor 76 - participant DL as Download Task 77 - 78 - Client->>HTTP: POST /api/v1/ingest 79 - HTTP->>Actor: QueueUrl(url) 80 - HTTP-->>Client: {ok: true} 81 - Actor->>Actor: insert placeholder track 82 - Actor->>DL: spawn yt-dlp download 83 - DL->>Actor: DownloadReady (file + metadata) 84 - Actor->>Actor: update track metadata 85 - Actor->>Client: state snapshot via WS 86 - ``` 87 - 88 - ### HTTP Range media serving 89 - 90 - Tracks are downloaded to disk as standard MP4 files via yt-dlp. The server 91 - serves them via HTTP Range requests, enabling `<video>` / `<audio>` seeking 92 - without any transcoding or special streaming protocol. 93 - 94 - ``` 95 - GET /api/v1/rooms/{room_id}/media/{track_id} 96 - Range: bytes=0-1023 97 - ``` 98 - 99 - ### Wall-clock sync 100 - 101 - The server publishes `started_at` (unix ms) for the active track. Each client 102 - computes its position: 103 - 104 - ``` 105 - video.currentTime = (Date.now() - track.startedAt) / 1000 106 - ``` 107 - 108 - Drift correction: every 10s, if |serverTimeline - clientPosition| > 300ms, 109 - nudge `currentTime` by 20% of the diff. 110 - 111 49 ### Perpetual playlist 112 50 113 51 Rooms have a persistent playlist. When the queue is empty and the room is idle, 114 52 the next playlist entry is auto-filled. Entries are pre-resolved (downloaded) 115 53 at add-time so auto-played tracks never show "Loading...". 116 - 117 - ### WebSocket Protocol (JSON, no MoQ) 118 - 119 - Single endpoint: `GET /api/v1/ws/{room_id}?name=<username>` 120 - 121 - **Server → Client (Text frames):** 122 - - State snapshot: `{ "room_name", "current_track", "queue", "history", "playlist", "clients" }` 123 - - Chat message: `{ "id", "user_name", "content", "msg_type", "created_at" }` 124 - 125 - **Client → Server (Text frames):** 126 - - `{ "type": "chat", "content": "..." }` 127 - - `{ "type": "skip" }` 128 - - `{ "type": "track_ended", "item_id": "..." }` 129 - - `{ "type": "ping" }` 130 - 131 - ### Room lifecycle 132 - 133 - Rooms are never deleted from the store — only the in-memory actor is dropped. 134 - `Registry::handle()` rehydrates from SQLite on demand. Stale active tracks 135 - (server crashed mid-playback) are moved to history on rehydration. 136 - 137 - ```mermaid 138 - stateDiagram-v2 139 - [*] --> Created: POST /api/v1/rooms 140 - Created --> Active: first client connects 141 - Active --> Idle: all clients leave 142 - Idle --> Active: client reconnects (rehydrate) 143 - Idle --> Swept: 10min idle timeout 144 - Swept --> Active: handle() rehydrates from store 145 - Swept --> [*]: room deleted 146 - ``` 147 - 148 - ## Source Layout 149 - 150 - ``` 151 - src/ 152 - ├── main.rs — CLI entry point, server bootstrap, shutdown signal 153 - ├── room.rs — Room actor (mpsc event loop), Registry (room lifecycle) 154 - ├── state.rs — Pure PlaybackState machine (no IO) 155 - ├── web.rs — Axum router, handlers, rate limiter 156 - ├── transport.rs — Simple WebSocket JSON protocol (~120 lines) 157 - ├── ingest.rs — yt-dlp download + metadata extraction 158 - ├── media.rs — HTTP Range file serving (~145 lines) 159 - ├── store.rs — RoomStore trait, InMemoryStore, SqliteStore 160 - ├── types.rs — Shared types (RoomId, TrackId, TrackState, RoomPublishers, etc.) 161 - ├── names.rs — Deterministic adjective-noun room name generator 162 - └── util.rs — now_iso() helper 163 - frontend/ 164 - ├── src/ 165 - │ ├── main.tsx — SolidJS app entry 166 - │ ├── App.tsx — Root component 167 - │ ├── Player.tsx — Media player with wall-clock sync 168 - │ ├── Queue.tsx — Queue + playlist display 169 - │ ├── Chat.tsx — Chat component 170 - │ ├── ws.ts — WebSocket client 171 - │ └── types.ts — Frontend type definitions 172 - ├── index.html — Dev entry point 173 - ├── vite.config.ts — Vite config (builds to ../static/dist) 174 - └── package.json — SolidJS dependencies 175 - static/ 176 - ├── dist/ — Built frontend (pre-committed) 177 - │ ├── index.html 178 - │ └── assets/ 179 - └── index.html — Fallback for `cargo run` without built frontend 180 - ``` 181 - 182 - ## HTTP API 183 - 184 - ### Create Room 185 - ``` 186 - POST /api/v1/rooms 187 - Response: { "room_id": "<uuidv7>" } 188 - ``` 189 - 190 - ### Queue a URL 191 - ``` 192 - POST /api/v1/ingest 193 - Body: { "url": "https://youtube.com/watch?v=...", "room_id": "<uuid>" } 194 - Response: { "ok": true } 195 - Errors: 400 (bad URL/room ID), 404 (room not found), 429 (rate limited, Retry-After header) 196 - Rate limit: 1 request per 5 seconds per IP 197 - ``` 198 - 199 - ### Skip Current Track 200 - ``` 201 - POST /api/v1/skip 202 - Body: { "room_id": "<uuid>" } 203 - Response: { "ok": true } 204 - ``` 205 - 206 - ### Playlist Management 207 - ``` 208 - POST /api/v1/playlist/add 209 - Body: { "room_id": "<uuid>", "url": "https://..." } 210 - Response: { "ok": true } 211 - 212 - POST /api/v1/playlist/remove 213 - Body: { "room_id": "<uuid>", "id": "<playlist-entry-uuid>" } 214 - Response: { "ok": true } 215 - ``` 216 - 217 - ### Media Serving 218 - ``` 219 - GET /api/v1/rooms/{room_id}/media/{track_id} 220 - Headers: Range: bytes=<start>-<end> (optional) 221 - Response: 206 Partial Content with file bytes, or 200 OK for full file 222 - ``` 223 - 224 - ## State Machine 225 - 226 - Events: `TrackQueued`, `Skip`, `TrackEnded`, `MetadataUpdated`. 227 - 228 - Effects: `AbortDownload`, `PersistStarted`, `PersistFinished`, `PublishSnapshot`, 229 - `PersistQueuedTrack`, `PersistMetadata`, `PersistChat`, `AddPlaylistEntry`, 230 - `UpdatePlaylistEntry`, `RemovePlaylistEntry`. 231 - 232 - ## Tech Stack 233 - 234 - | Layer | Technology | 235 - |-------|-----------| 236 - | Runtime | Tokio (full) | 237 - | Web | Axum 0.8 + WebSocket | 238 - | Frontend | SolidJS 2 + Vite | 239 - | DB | SQLite via SQLx 0.8 (WAL mode) | 240 - | CLI | Clap 4 (derive + env) | 241 - | Logging | tracing + tracing-subscriber | 242 - | Build | Nix Flakes (crane + rust-overlay) |