# Zuzufy architecture

```
                 ┌──────────────────────────────┐
                 │  Web client (ES modules/PWA) │
                 │  LinearBroadcastPlayer,      │
                 │  LiveClient, PeerSwarm       │
                 └──────┬──────────┬──────┬─────┘
          HTTPS (REST,  │   WS /ws │      │ WebRTC
          signed media) │          │      │  ├─ media (SFU)      → mediasoup
                        ▼          ▼      │  └─ data channels    → other listeners (P2P chunks)
               ┌─────────────────────────────────────────┐
               │ Node.js (one process per instance)      │
               │  Express API · WebSocket hub            │
               │  BroadcastManager / BroadcastEngine     │
               │  Live signalling · P2P tracker          │
               │  Media worker (FFmpeg) · periodic jobs  │
               └──────┬──────────────┬───────────────┬───┘
                      ▼              ▼               ▼
                   MariaDB      StorageService    mediasoup workers
                               (local/S3/CDN)     (one per CPU core)
                      ▲              ▲
                      │       Nginx/CDN serves media bytes (secure_link)
```

## 1. The broadcast clock (the core idea)

A channel's 24/7 programming is **metadata**, not a giant audio file. Each media item stays an
individual file; the schedule is an ordered list of items with durations and transitions.

`src/broadcast/BroadcastEngine.js` is a pure module: `(publishedSchedule, channelState, nowMs) → state`.
No I/O, no randomness, no `Date.now()` inside — so every listener and every server instance computes
the same answer for the same instant.

### Segments

| Mode | Segment for instant *t* | Anchor |
|---|---|---|
| `loop` | one infinite segment | schedule epoch (publish time, or kept on republish) |
| `exact24` | the local calendar day containing *t* | local midnight in the channel time zone |
| `daily` | the block (e.g. 08:00 Morning Show) active at *t* | that block's local start; ends at the next block |

Within a segment:

```
position  = (t − anchor) mod playlistDuration
track     = item with startMs ≤ position < next.startMs     (binary search)
offset    = position − track.startMs
endsAt    = t + (track.endMs − position)   (capped at segment end)
```

Items are laid out by `compilePlaylist`: gapless/cut/fade items start where the previous ended,
`crossfade` items overlap the previous item, `silence` items start after a gap. Local wall-clock
times are converted with `Intl` (`timezone.js`): DST gaps resolve forward, overlaps to the earlier
instant, and `exact24` days are 23 or 25 hours long when the clocks change.

### Publishing

Creators edit playlists and the schedule freely (draft). **Publish** validates the draft
(`scheduleValidator.js`: no missing/unprocessed media, durations, duplicate sequences, exact-24h
totals, block overlaps; warnings for gaps, repeats, truncation) and freezes it into
`broadcast_schedules.published_snapshot` with an incrementing version. The engine only ever runs
from a snapshot, so the on-air timeline is deterministic and auditable.

### Live overrides

`broadcast_state.state` is a state machine: `OFFLINE → AUTOMATED ⇄ LIVE`, `PAUSED`, `EMERGENCY`,
`ENDED` (allowed transitions in `canTransition`). When a live show ends, the resume policy is
expressed as a **time shift** applied to one segment's anchor — still deterministic:

- `continue`: no shift (air whatever is scheduled now)
- `resume`: shift by the live duration (pick up exactly where the live show cut in)
- `next_item`: shift so the item after the interrupted one starts now
- `insert_recording`: re-air the processed recording as an *interstitial*, then resume

If the host's connection drops, a grace timer (`LIVE_HOST_GRACE_SECONDS`) lets them rejoin; otherwise
the session fails over to automated programming.

## 2. Synchronised playback (client)

`public/js/player/clock.js` estimates server time NTP-style (lowest-RTT sample from `/api/time` and
WebSocket `time.sync`) and advances it with `performance.now()`, so device clock changes never affect
playback. The browser clock is never trusted for position.

`LinearBroadcastPlayer` receives `broadcast.sync` / `broadcast.track_changed` and plays
`expected = (serverNow − track.startedAtMs)/1000` on one of two decks (Web Audio gain per deck for
fades/crossfades). A 1-second drift loop applies the configured thresholds: ignore < 1 s, nudge
`playbackRate` ±5 % above 2 s, hard-seek above 5 s. The next track is preloaded 20 s early and started
exactly at its scheduled instant. After any reconnect the server state is applied as-is (the player
never resumes an old local position). Optional time-shift (−30 s / −1 min / −5 min / restart show)
follows `GET /api/channels/:id/broadcast-sync?at=…` and shows **Return to live**.

## 3. Realtime fan-out and scaling

`BroadcastManager` (one per process) caches compiled snapshots and state per channel and arms **one
timer per channel that has viewers**, firing at the next track boundary to push
`broadcast.track_changed`. Channels nobody is watching cost nothing; thousands of channels share one
process. Rooms: `channel:{id}` (page viewers: state, chat), `listen:{id}` (active listeners, for
counts), `user:{id}` (notifications), `live:{id}` (WebRTC signalling).

**Horizontal scaling.** Run several Node instances behind the load balancer (sticky WebSockets not
required for correctness). What needs sharing:

- `Hub` has an adapter seam (`setAdapter({ publish, subscribe })`) — plug Redis pub/sub so
  `hub.publish` reaches sockets on other instances.
- `BroadcastManager` timers are idempotent pushes; with Redis, elect one publisher per channel or let
  each instance push to its own sockets (current behaviour, already correct because state is computed
  from the shared DB).
- The rate limiter's `MemoryStore` has the same `hit()` interface a Redis store would implement.
- The media worker queue is already multi-instance safe (`FOR UPDATE SKIP LOCKED`).

Media bytes never flow through Node in production: Node issues signed URLs; Nginx/CDN serves them.

## 4. Live broadcasting (WebRTC SFU)

`src/live/sfu.js` runs one mediasoup worker per CPU core. Each live session has an **origin router**
(host + guests produce there) and **edge routers** for viewers; when an edge reaches
`LIVE_VIEWERS_PER_ROUTER`, a new edge is created on the next worker and every producer is piped to it
(`pipeToRouter`). No mesh: each listener has exactly one receive transport. Signalling messages are in
`docs/api/websocket.md`. Beyond one machine, the same pipe mechanism extends to remote SFU nodes, or
the origin feeds an HLS packager (deferred).

**Recording**: the host studio mixes host + guest audio with Web Audio (plus the host camera/screen)
and uploads MediaRecorder chunks sequentially with retries (`POST /api/live/:id/recording`,
`X-Chunk-Seq`). On stop the file is moved to private storage and processed like any upload (MP3 and
MP4 renditions, waveform). When processing completes, a **draft episode** is created for review and,
if configured, the AI pipeline runs.

## 5. Peer-to-peer delivery

Every listener of a 24/7 channel needs the same bytes at the same moment, which suits peer exchange.

- **Manifests** (`src/p2p/manifest.js`): each broadcast rendition is split into 64 KB chunks; the
  manifest lists every chunk's SHA-256, the whole-file hash and the CBR time↔byte mapping. Served at
  `GET /api/p2p/manifest/:mediaId?channelId=` only to listeners allowed on the channel.
- **Tracker** (`src/p2p/tracker.js`): WebSocket `p2p.join` returns random peers in the channel's swarm
  and relays SDP/ICE (`p2p.signal`). Peers are identified by random per-connection ids. STUN only.
- **Client** (`public/js/player/p2p/`): `PeerSwarm` keeps WebRTC data channels to up to 8 peers, an
  LRU chunk store (48 MB) and announces holdings (`have`/`got`). `P2PTrackStream` plays a track via
  Media Source Extensions, requesting only the chunks needed from the current position. Scheduling is
  urgency-based: chunks due soon come from the origin (signed HTTP Range), chunks due later wait
  briefly for peers. **Every chunk is verified** before use or re-sharing; a peer sending bad data is
  disconnected. Any failure falls back to plain HTTP playback.
- **Controls**: platform `P2P_ENABLED`, per-channel *Delivery* setting, per-listener *Share bandwidth*
  preference (explains IP visibility). Offload is measured per session (`p2p_bytes`/`http_bytes`) and
  reported in creator and admin analytics.

## 6. Media pipeline

Uploads stream to disk (busboy, hard size limits), are type-sniffed by magic bytes, confirmed by
ffprobe, optionally malware-scanned (`MALWARE_SCAN_COMMAND`), stored under a server-generated key and
queued in `media_processing`. The worker produces the loudness-normalised (EBU R128, −16 LUFS) 192 kbps
CBR MP3 broadcast rendition (accurate durations matter for the clock), waveform peaks, video thumbnails
and MP4 renditions of WebM, and the P2P manifest. Jobs are durable, retried with backoff and re-queued
if a worker dies.

## 7. Storage abstraction

`StorageService` exposes `upload`, `download`, `delete`, `getUrl`, `getSignedUrl`, `stat`,
`withLocalFile`. To add S3-compatible storage, implement a provider with the same methods
(`withLocalFile` downloads to a temp file for FFmpeg) and point `PUBLIC_MEDIA_URL` at the CDN in front
of the bucket; keep the `secure_link` signature check at the edge or switch `getSignedUrl` to presigned
URLs.

## 8. AI pipeline

`src/ai/AIProcessingService.js`: transcription provider (`whisper-cpp`, local) → text provider
(`anthropic`: Claude with a strict JSON schema) → suggestions stored on the episode
(`ai_suggestions`). Creators review and apply individual suggestions in the editor; nothing is applied
or published automatically. Providers are swappable without touching business code.

## 9. Merch (Torvali commerce)

`src/commerce/torvaliClient.js` talks to the creator's Torvali store (Desktop/F9Networks/Shopify):
the public storefront API for the catalogue (cached 2 minutes) and the merchant login once, to verify
ownership when connecting. `src/services/shopService.js` keeps one `channel_shops` row per channel
(store name, featured ids, pinned product, visibility). Pinning publishes `shop.pinned` to
`channel:{id}`, so listeners see the product immediately. Click-throughs go via `/go/shop/…`
(recorded as `shop_click` analytics events) to Torvali, which handles checkout and fulfilment.
If Torvali is unreachable, the channel page simply hides the shop and the creator view shows a warning.

## 10. Data model

49 tables across migrations `001`–`013`: identity (`users`, `roles`, `user_roles`, `sessions`,
`auth_tokens`), channels & media, broadcast (`playlists`, `playlist_items`, `broadcast_schedules`,
`broadcast_state`, `broadcast_instances`), membership & payments (`subscription_plans`, `subscriptions`,
`redemption_codes`, `payments`, `payment_events`, `creator_payouts`), content (`episodes`, `rss_feeds`,
`comments`, `favorites`, `user_playlists`), social (`chat_*`, `reports`, `moderation_actions`,
`notifications`), analytics (`listening_sessions`, `analytics_events`), live (`live_sessions`,
`live_participants`, `live_recordings`), advertising (`advertisers`, `ad_campaigns`, `ad_placements`), merch (`channel_shops`), rights (`media_rights_attestations`),
and `audit_logs`, `settings`. All timestamps are UTC `DATETIME(3)`.
