# Cloud Upload v1 — Shaped Brief > Approved: 2026-03-18 > Deliberation: 3 models (Codex scope, Gemini challenge, Claude requirements) ## Problem MixBoard can browse and stream the chad-music cloud library but can't add music. Adding music currently requires SSH + torrents/slskd + beets import — no path from the macOS app. ## Goal Upload a local audio file from MixBoard to the chad-music server, where it becomes immediately available for streaming (via taglib rescan), and is later enriched by beets running as a background cron job. ## Non-goals (v1) - Multi-file upload (one at a time is fine) - Dedup (beets background handles it implicitly; no server-side dedup today) - Resumable uploads - Auto-upload on play - Upload queue persistence across app restarts - Drag-and-drop from Finder - Batch album upload with metadata editing UI ## Acceptance Criteria - [ ] User can select a local audio file via toolbar button → file picker and upload it to chad-music - [ ] Server saves the file to a designated upload directory, then triggers rescan - [ ] Upload progress (bytes sent) is visible in the UI as a progress indicator - [ ] After rescan completes, server returns success and the track is browsable in cloud library - [ ] If upload or processing fails, user sees a clear error message - [ ] User can cancel an in-progress upload ## Appetite Small-Medium: - Server: 1 new endpoint (PUT /api/upload), ~3 functions in server.lisp, nginx config change (client_max_body_size), cron job for beets - Client: 1 new service file (UploadService.swift), toolbar button + progress indicator in existing views - No new dependencies on either side ## Architecture ### Upload protocol: Raw body PUT (not multipart) ``` PUT /api/upload Headers: Authorization: Bearer X-Filename: "05 - Echoes.flac" Content-Type: audio/flac (or audio/mpeg, audio/mp4, etc.) Content-Length: Body: raw audio file bytes Response (synchronous): 200 {"status": "imported", "tracks_added": 1, "albums_updated": 1} 409 {"status": "error", "message": "File already exists"} 413 {"status": "error", "message": "File too large"} 400 {"status": "error", "message": "Unsupported format"} ``` ### Server-side flow (immediate) 1. `with-user` auth check 2. Validate Content-Type (allow mp3, flac, m4a, wav, aiff, ogg) 3. Read raw body → stream to disk in upload directory (e.g., `/data/upload/mixboard/-`) 4. Trigger synchronous rescan of the upload directory 5. Return result with added/updated counts ### Server-side flow (background — beets) - Cron job (e.g., every 5 min): `beet import --move --quiet /data/upload/mixboard/` then `curl -X POST http://localhost:5000/api/rescan` - This is the same pattern chatikbot already uses (run-import → request-chad-music-rescan) - **Zero server.lisp changes needed** for the beets part — just a cron entry ### What beets does to uploaded files - **Recognized files**: beets moves from upload dir to organized library path (Artist/Album/Track.ext), enriches tags with MusicBrainz data, downloads cover art. On next rescan: old path entry disappears, new enriched entry appears. - **Unrecognized files**: beets skips them (--quiet). They stay in upload dir, remain visible in library with their original embedded tags. No data loss. ### Client-side flow 1. Toolbar button → NSOpenPanel (single file, audio UTTypes) 2. URLSession upload task with PUT, progress via delegate 3. Progress indicator (linear bar or circular) near toolbar 4. On success: "Track uploaded!" toast + auto-refresh cloud browser 5. On failure: error alert with server message 6. Cancel via URLSession task cancellation ## Technical Constraints - Nginx: set `client_max_body_size 200m` in server block - Server: file must be written by the same user that runs chad-music (so rescan can read it). No sudo needed for the upload endpoint. - Upload directory must be under a path that config.lisp maps to a URL prefix (so file-server can serve it for streaming) - Beets cron runs as user `uploader` (existing pattern from chatikbot) - Accepted formats: mp3, flac, m4a, aac, wav, aiff, ogg ## Dependencies & Blockers - **Verify** Woo/SBCL can stream request body to disk without loading into memory (critical for 100MB+ FLAC files — test with curl upload first) - Nginx `client_max_body_size` config change - Enikesha needs to review/merge server-side changes (PR workflow) - Beets cron job needs to be added on the server ## Risks 1. **Woo body streaming**: if Woo buffers entire body in memory, 100MB FLAC = OOM. Mitigation: test early with curl. Fallback: nginx upload module to buffer to disk before proxying. 2. **File organization**: without beets (before cron runs), uploaded files land in a flat upload directory. Not terrible — they're playable immediately. Beets organizes them on its next run. 3. **OGG files**: chad-music's taglib parses OGG, but MixBoard's AVPlayer can't play OGG streams. Upload works, playback doesn't. Document this limitation. 4. **Two-state tracks**: after upload, track appears with original tags. After beets runs, it "moves" (new path = new track ID). For single user, acceptable. ## Deferred to v2 - Multi-file / album batch upload - Server-side dedup (chromaprint/acoustid) - Drag-and-drop from Finder to cloud section - Upload from "now playing" context menu - Auto-upload toggle - Resumable uploads - Upload history view - Beets on the synchronous upload path (instead of background cron)