# Touchline Analysis Studio: authenticated positional feed, version 1

This is the open contract a licensed player/ball tracking provider (or an adapter in front of one)
implements so the Studio can show a live watch-along radar. Files:

- `positional-feed-v1.schema.json`: JSON Schema (2020-12) for one feed page.
- `positional-feed-v1.example.json`: a valid page.

**No provider is built in.** An administrator configures each feed (its provider adapter, how the
server connects, its URL, its sign-in secret and who may use it). The credential stays on the Touchline server and is never sent to a browser. The
Studio does not guess any vendor's endpoint. API-Football / API-Sports supply events, lineups and
statistics only, not positions, and are a separate connector.

## Request

The Touchline server polls the configured URL:

```
GET <configured URL>?fixture=<fixture id>[&after=<checkpoint>]&limit=<1..512>
Accept: application/vnd.touchline.positional-feed+json; version=1
Accept-Encoding: identity
<configured credential header>: <configured credential>
```

- No `after`: start of the retained stream (a backfill).
- `after`: the `checkpoint.next` value from an earlier page, sent back verbatim. A feed that can no
  longer serve that checkpoint answers `410 Gone`; the Studio then backfills from the start.
- Existing query parameters in the configured URL are kept; the three above are replaced.
- If the configured URL contains placeholders (see "Admin configuration fields" below), it is used as written
  with the placeholders filled in, and these three parameters are not added.
- The credential header above is one of five sign-in methods; see "Admin configuration fields" below.

### Push transports (WebSocket and Server-Sent Events)

The same v1 page document can also be pushed:

- **WebSocket** (`wss://` only): each text or binary message is one complete page document.
- **Server-Sent Events** (`https://`, `Accept: text/event-stream`): each default (`message`) event's
  `data` is one complete page document. Events with any other name and `:` comment lines count as
  activity only. An `id:` is sent back as `Last-Event-ID` on reconnect.

A pushed page carries no per-message headers, so it is bound by its body (`feed.id`, `fixture.id`)
only. Without placeholders the server connects to the configured URL with `fixture=<fixture id>` and,
on a reconnect, `after=<last checkpoint.next>`. Studio users read pushed frames from a bounded server
buffer through the same page route; the checkpoints they hold are the server's own.

## Response

Status `200` with an unencoded body of at most 2 MiB and these headers, all required:

| Header | Value |
|---|---|
| `Content-Type` | `application/vnd.touchline.positional-feed+json; version=1` |
| `X-Touchline-Feed-Id` | the configured feed ID, equal to `feed.id` in the body |
| `X-Touchline-Feed-Fixture` | the requested fixture ID, equal to `fixture.id` in the body |
| `X-Touchline-Content-SHA256` | lowercase hex SHA-256 of the exact body bytes |

A page whose headers or body disagree with the request or configuration is rejected whole. Redirects
are never followed. `429`/`503` with `Retry-After` are honoured.

## Semantics

- **Clock.** `period` 1–4 and integer `periodMs` on that period's own clock. `capturedAt` is the
  provider's wall-clock time for the frame (optional). Nothing is interpolated or resampled.
- **Frames.** Each frame has a `sequence` (unique within a `stream`) and a `revision`. A higher
  revision of the same sequence replaces the earlier one (a correction). Repeating the same sequence
  and revision with different content is a contradiction and the page is rejected. Pages may repeat
  frames (duplicates are dropped) and may be out of order (the Studio orders by clock).
- **Coverage.** `complete-visible-set` lists every visible person; an omitted person was not visible.
  `partial` makes no claim about omitted people. A ball is `known`, `unknown` (stated unknown) or
  `unreported`. Unknown is never filled in from an earlier frame.
- **Invalidations.** `{period, fromMs, toMs, throughRevision}` voids every earlier frame in that
  clock range whose revision is at most `throughRevision`; corrected frames, if any, follow. The
  Studio discards the voided frames from its retained window and drops cached server pages.
- **Stream.** When `feed.stream` changes, all earlier checkpoints and frames are void.
- **Coordinates.** x runs along the pitch length and y across its width. `metres` or `centimetres`
  with `origin` `centre` (centre spot) or `corner`, with the venue's `pitchLength`/`pitchWidth` in
  metres; or `normalized` 0–1 from a corner (venue size optional). Ball height `z` is metres. Values
  are converted to the Studio's 105 × 68 normalization; the declared venue size is retained as
  physical scale. Positions outside the pitch surrounds are rejected, never clamped.
- **People.** `people` is the feed's own person dictionary (IDs and an optional provider label). A
  feed person is linked to a canonical player only by an explicit existing identity link; otherwise it
  stays an unlinked feed identity. Lineup or shirt numbers are never used to infer identity.

## Limits

| Limit | Value |
|---|---|
| Page body | 2 MiB, unencoded, 8 s timeout |
| Frames per page | 512 |
| People in the dictionary / per frame | 256 / 64 |
| Invalidations per page | 64 |
| Identifiers | 1–128 characters, no control characters |
| Checkpoint | 1–256 printable ASCII characters |
| Server polling | at most one upstream request per feed and fixture per configured interval (minimum 250 ms); results are shared |
| Server burst budget | at most 20 upstream requests per feed in any 10 seconds, across all fixtures |
| Studio retained window | 30 seconds to 10 minutes of the latest period clock (default 2 minutes), at most 8,192 frames |

| Push connections | at most 4 at once on the server and 2 per account; each only while a Studio user is asking for that fixture (60 s lease) |
| Push message | 2 MiB (WebSocket messages over it are closed with 1009); no compression |
| Push buffer per fixture | 6,000 frames or 8 MiB, oldest dropped first (an evicted checkpoint answers "expired") |
| Push inbound rate | 32 MiB in any 10 seconds per connection |
| Push heartbeat | WebSocket ping every 15 s; no message, pong or SSE comment for 30 s reconnects |
| Push reconnect | exponential backoff from 1 s to 30 s, `Retry-After` honoured (up to 5 minutes) |

## Security

The server only connects to the administrator's configured HTTPS URL. Every DNS answer must be a
public unicast address (private, loopback, link-local, carrier-grade NAT, multicast, documentation,
NAT64/6to4 and other reserved ranges are refused) and the connection is pinned to the checked
address. Bodies must be unencoded and bounded. The same check and pinning apply to every URL the server
connects to: the poll URL, the WebSocket or SSE stream URL and the OAuth2 token URL. Users choose only
among feeds they are permitted to use; they cannot supply a URL, header or credential.

## Connecting a licensed provider

Touchline does not ship any vendor's endpoint paths, handshakes or field names, and does not guess
them. Each licensed provider (SkillCorner, Stats Perform / Opta Vision, Second Spectrum / Genius
Sports, TRACAB, or any other) is connected through a **provider adapter** that decodes that provider's
own payloads into v1 pages. The open v1 contract above is the first adapter (*Touchline positional
feed v1*); a provider that implements it, or a proxy in front of one, needs no adapter of its own.
Everything specific to a contract is entered by the administrator in **Admin, Settings, Studio
positional feeds**, from the provider's own contract documentation.

### What to request from each provider

Ask the provider (their contract documentation, or their integrations team) for each of these, and
put the answer where the right-hand column says:

| Ask the provider for | Where it goes in the admin card |
|---|---|
| Whether live positions are **pulled** (HTTP polling) or **pushed** (WebSocket or Server-Sent Events) | **Connection** |
| The exact live endpoint: host, path and query, and how a match and a resume point are named in it | **Feed URL** / **Stream URL**, with placeholders |
| How a client authenticates: a header and its name, a bearer token, HTTP Basic, OAuth2 client credentials (token endpoint, client ID and secret, scope, whether the client secret goes in a Basic header or the form body), or an API key in the query | **Sign-in method** and its fields; the secret goes in **Credential** |
| Any subscription message a WebSocket expects after connecting, and how it names the match and resume point | The provider's adapter (not the admin card) |
| Their match / fixture identifiers and how to look them up for a Studio fixture | **Fixture mapping** |
| Their own feed or stream identifier that appears in each payload | **Remote feed ID** |
| The coordinate system: units, origin (centre spot or a corner), axis directions, whether the pitch size is in the payload, the venue sizes | **Coordinate frame** (only if the adapter says it needs one) |
| Frame rate (Hz), ball height availability, whether player and official IDs are stable, event markers | Shown as the adapter's capabilities; tells you what the radar will have |
| How a dropped connection resumes (cursor, sequence, timestamp, `Last-Event-ID`) and how long data is retained | **Feed URL** placeholders `{cursor}` / `{since}`; the adapter's resume support |
| Rate limits and connection limits per account, and how they signal them (`429`, `503`, `Retry-After`) | **Poll interval**; the server's push limits above |
| Payload size per message or page, compression, and any time-delay or embargo in the licence | Must fit the limits above; compression is refused |
| Allowed IP addresses, if they restrict by source address | Give them the Touchline API's outbound address |

### Admin configuration fields

- **Provider adapter**: which decoder reads the payloads. Its capabilities (ball height, player IDs,
  officials, event markers, resume) are shown under the form.
- **Connection**: *Poll over HTTPS*, *WebSocket stream (wss)* or *Server-Sent Events stream (https)*,
  limited to what the adapter supports.
- **Feed URL / Stream URL**: `https://` (poll, SSE) or `wss://` (WebSocket). It may contain these
  placeholders in its path or query (never in the host or port):

  | Placeholder | Filled with |
  |---|---|
  | `{fixtureId}` | the provider's fixture ID (after the fixture mapping) |
  | `{cursor}` | the resume point the adapter last returned (empty on a fresh start) |
  | `{since}` | a time-based resume point, for adapters that resume by time (empty otherwise) |

  Values are percent-encoded; a query parameter whose value comes out empty is left out. A value
  placed in the path may not contain `/`, `\`, `%` or `..`, so it always stays one path segment.
- **Remote feed ID**: the feed identity every payload must carry.
- **Sign-in method** (one secret per feed, sealed at rest with the server's key, never shown again,
  only a fingerprint is displayed; changing where or how it is sent asks for it again):

  | Method | Sends |
  |---|---|
  | Credential in a named header | `<header>: <secret>` |
  | Bearer token | `Authorization: Bearer <secret>` |
  | HTTP Basic | `Authorization: Basic base64(<user name>:<secret>)` |
  | OAuth2 client credentials | a `client_credentials` token request to the token URL (client ID and secret in a Basic header or the form body, optional scope), then `Authorization: Bearer <token>`. Tokens are kept in memory until shortly before they expire and requested again once after a `401`. |
  | API key in the URL | `?<parameter>=<secret>`. **Weaker:** URLs are often written to logs along the way. Use only when the provider offers nothing else. |

- **Fixture mapping**: one `Studio fixture = provider fixture` per line. Empty: the Studio fixture ID
  is sent as it is. Once any mapping exists, only mapped fixtures are served.
  The Studio's Live page (`/analytics/live`) offers exactly the mapped fixtures to pick from.
- **Optional fixture index** (open-contract feeds without a fixture mapping): the server may read
  `GET <feed origin>/` (no credential, JSON, at most 64 KiB, cached for a minute) and offer the
  fixtures it lists: `{ "fixtures": [{ "id": "…", "mode": "normal" }], "kickoff": "every 120 minutes (UTC)"
  or an ISO date-time, "matchDurationMs": 6388560, "match": { "home": "…", "away": "…" } }`. Every
  field but `fixtures` is optional. A host without a readable index lists nothing and the presenter
  types the fixture ID. The DFL replay feed (`packages/feed-replayer`) serves one.
- **Coordinate frame**: for adapters whose payloads do not state their coordinates (metres or
  centimetres from the centre spot or a corner with the venue length and width, or fractions from a
  corner).
- **Metadata URL** (adapters that read one: required for TRACAB, Stats Perform and SkillCorner,
  optional for Second Spectrum when the pitch size is declared): an `https://` URL, whose only
  placeholder may be `{fixtureId}`, for the provider's separate metadata document (lineups, periods,
  pitch size). It is fetched through the same address checks and with the same sign-in and secret as
  the feed URL (so moving it to another host asks for the credential again), is
  bounded at 1 MiB, and is cached per feed configuration and provider fixture. It is fetched again
  after 10 seconds (live metadata changes as periods start and end), when a payload fails to bind
  to it (for example a late substitute missing from an older lineup), and never per frame. Each fetch
  spends the feed's shared request budget (20 per 10 seconds). While the metadata endpoint is
  unreachable or busy (or the budget is spent) the last good document is used for up to 60 seconds,
  with a back-off between attempts; a refused document (another fixture's, unreadable, or a 4xx
  answer) is reported at once.
- **Envelope frames path / Envelope cursor path** (adapters that read a native data format): leave
  both empty when each response or message is the bare native data. When the provider wraps it in
  JSON, give the dot path of the list of native frames (for example `data.frames`) and, if it sends
  one, the dot path of its resume cursor (for example `meta.next`), which then fills `{cursor}`. An
  envelope whose cursor is absent or null (a provider at the live edge) keeps the previous cursor.
- **Poll interval / Studio refresh interval**, **Allowed account IDs**, **Enabled**.

### Native-format adapters: TRACAB, Stats Perform, SkillCorner and Second Spectrum

These adapters decode each provider's **documented native data format**, read exactly as the
open-source [kloppy](https://github.com/PySport/kloppy) 3.19.1 readers read them (and SkillCorner's
open data). Their **live** APIs (endpoints, sign-in handshake, push framing and resume) are
contract-only and are not guessed: the administrator enters them from the provider's contract, using
the URL placeholders, the sign-in method, the metadata URL and the envelope paths above. **Each adapter
is marked "Needs acceptance against the provider's live feed" until a real licensed feed has been
decoded end to end.** What they share:

- One checkpoint: `f<last frame number>[~<envelope cursor>]` (`f-` before any frame). `{since}` sends
  back the last frame number received (frames the adapter skipped, such as pre-match frames, count, so
  a page of only skipped frames still moves on) and `{cursor}` the envelope cursor; a frame number is
  never sent as `{cursor}`. An empty answer keeps the previous checkpoint. A cursor that cannot be
  placed in the URL (a `/`, `\`, `%` or `..` in a path placeholder) fails the feed; put `{cursor}` in
  the query if the provider's cursors can contain those.
- Native frames do not name their match, so the feed URL and the metadata URL must contain
  `{fixtureId}` (or exactly one fixture is mapped), and the metadata's own game ID is checked against
  the fixture. On a push feed the metadata is fetched and checked before connecting; a wrong or
  refused metadata document stops the feed with an error rather than reconnecting.
- Each page is built as a version 1 document and passes every v1 check. Native frames have no wall
  clock, so `capturedAt` is empty and `generatedAt` is the server's receipt time. One response or
  message is read at most 512 frames at a time: a longer one is cut after its 512th frame (in order) and
  answered with `caughtUp: false` and a checkpoint at that frame, with the provider's cursor kept where it
  was, so the next request (`{since}`) continues with the rest and nothing is dropped; a push feed
  reconnects from that checkpoint for the rest. Malformed frames reject the whole payload; nothing is repaired.
  A ball height outside 0 to 40 m (sensor noise such as -0.02 m) makes the ball height unknown for that
  frame, with its x and y kept and nothing clamped; a height that is not a finite number is malformed.
- Player identity is the provider's IDs (or shirt numbers), never names. The **Remote feed ID** is not
  carried in the native formats and is not checked; enter the provider's name.

#### TRACAB and Stats Perform / Opta Vision

Read from kloppy 3.19.1 at commit `bba510091e9a`.

**TRACAB (ChyronHego)**: adapter *TRACAB tracking (DAT or JSON, with TRACAB metadata)*.

- Frames: the DAT text format (one `frameId:objects;...:ball;:` line per frame) or the TRACAB JSON
  format (`FrameData`), bare or as items of an envelope (a DAT line as a string, a JSON frame as an
  object).
- Metadata URL: required. The TRACAB metadata document (flat XML, hierarchical XML or JSON). It gives
  the GameID (it must equal the provider fixture, or every payload is refused as another fixture's),
  the frame rate, the pitch size and the start and end frame of each period, and the lineup used to
  turn shirt numbers into player IDs. The coordinate frame is therefore not declared in the card.
- Coordinates are centimetres from the centre spot with y toward the top touchline (converted to the
  Studio's frame). Ball height is supplied; ball owner and alive/dead are checked but not shown.
- Object codes -1, 3 and 4 are skipped as kloppy skips them, so **officials are not shown** until
  TRACAB's own specification confirms which code is the referee.
- Live-only readings to confirm with TRACAB: a period whose end frame is still 0 is the period in
  progress; frames between declared periods (half-time) are dropped, as kloppy drops them; a frame
  past every declared period is never dropped or guessed: it waits for the metadata to declare its
  period (polling holds its resume point before it, so the provider must serve it again from
  `{since}` or `{cursor}`; a stream keeps up to 30 seconds of such frames); a missing ball section
  means the ball is unknown.
- Ask TRACAB for: the live frame endpoint and metadata endpoint, push or pull, the sign-in method,
  whether payloads are DAT or JSON and any JSON wrapper (frame list and cursor field names), how a
  client resumes after a gap (frame number or cursor), their GameIDs for each fixture (fixture
  mapping), and which object code is the referee.

**Stats Perform / Opta Vision**: adapter *Stats Perform / Opta Vision MA25 tracking (with MA1 metadata)*.

- Frames: MA25 tracking lines (`frameId;clock,period,status:side,player,jersey,x,y;...;:ball;`),
  bare (one or more lines per response or message) or as string items of an envelope. They decode
  exactly as the Studio's MA25 file import decodes them.
- Metadata URL: required. The MA1 match document (JSON): its match ID must equal the provider fixture,
  and every tracked player must be in its lineup on the side the line gives, or the payload is refused
  as another fixture's. A player missing from the lineup first makes the server fetch the MA1 again
  (a late squad change), then refuse.
- Coordinate frame: required. Declare **Metres from a corner** with the venue's length and width:
  MA25 is SportVU metres from the top-left corner and neither MA25 nor MA1 states the pitch size.
- Referees (side 2) are shown as officials with no team. Ball height is supplied; a height outside
  0 to 40 m is shown as unknown. Periods after 4 (shoot-outs) are not shown.
- Ask Stats Perform for: the live MA25 endpoint (and whether it is pushed), the MA1 endpoint for a
  fixture, the sign-in method (often OAuth2 client credentials: token URL, client ID, secret, scope),
  any JSON wrapper around the lines (line list and cursor field names), how a client resumes, their
  match IDs for each fixture, and each venue's pitch size.

#### SkillCorner and Second Spectrum

- Positions are metres from the centre spot with y pointing up (kloppy's coordinate systems for both
  providers), converted to the Studio's 105 × 68 with the pitch size from the metadata, or from a
  declared **Coordinate frame** (metres from the centre spot only: just the venue size is declared).
- Frames are `partial`: neither format says an omitted person was invisible. A ball stated as
  missing is `unknown`; a SkillCorner older-shape frame with no ball object leaves it `unreported`.
- One poll answer of at most 512 frames is about 20 s at 25 Hz; ask the provider for a page size or
  time window below that. Event markers are not read (feed v1 pages have no field for them).

**SkillCorner** (adapter `skillcorner`). Frames: `frame`, `timestamp`, `period`, `ball_data`,
`player_data` (`player_id`, `x`, `y`), or the older `data` list of trackable objects. The **Metadata
URL is required**: the match document (`id`, `home_team.id`, `away_team.id`, `players` with `id`,
`team_id`, `trackable_object`, `referees`, `ball`, `pitch_length`, `pitch_width`) maps players to teams.
Referees (older shape) become officials with no team, as `referee_<metadata referee id>`. The
metadata `id` is required and must equal the provider fixture. Ball height is kept when a frame has
it and lies within 0 to 40 m, else unknown (the capability says no, because SkillCorner does not promise it). 10 frames per second. Only periods 1 and 2 are
read: kloppy's extra-time clock offsets are its own guess, so extra time is refused until SkillCorner
confirms them. Ask SkillCorner for:

- the live tracking endpoint (pull or push), how a match and a resume point are named, and the
  wrapper around frames in each response or message (the envelope paths);
- the live match metadata endpoint (same sign-in?) and whether its `id` is the match ID used in the
  tracking URL;
- whether live frames use the `player_data` shape or the older `data` shape, and whether off-camera
  (extrapolated, `is_detected: false`) players are included live;
- the match-time format and clock offsets for extra time (periods 3 and 4);
- sign-in method, rate and connection limits, latency and any embargo.

**Second Spectrum / Genius Sports** (adapter `second-spectrum`). Frames are JSON Lines: `period`,
`frameIdx`, `gameClock` (seconds on that period's clock), `homePlayers` / `awayPlayers` (`playerId`,
`xyz`) and `ball.xyz`. The metadata (JSON with `pitchLength`, `pitchWidth`, `fps`, `ssiId`,
`homeOptaId`, `awayOptaId`; or XML with `fPitchXSizeMeters`, `fPitchYSizeMeters`, `iFrameRateFps`) is
optional when the pitch size is declared instead; without team IDs the teams are `home` / `away`.
The provider fixture must be the metadata's `ssiId` or `optaId` when it states them.
Ball height is supplied (outside 0 to 40 m it is unknown for that frame); frames carry no officials. Ask Second Spectrum / Genius Sports for:

- the live tracking endpoint and transport, how a game and a resume point are named, and the wrapper
  around frames in each message (the envelope paths);
- the live metadata endpoint and which game ID the live API uses (`ssiId` or `optaId`, checked against the fixture);
- whether `playerId` in live frames is the Opta ID, and whether it is always present;
- whether a negative ball height can occur (it is read as unknown for that frame, not rejected);
- sign-in method, rate and connection limits, latency and any embargo.

### Writing a provider adapter

An adapter is server code (`packages/api/src/analysis/live/positional/feedAdapters.ts`, the
`PositionalFeedAdapter` interface) that declares its transports, sign-in methods, capabilities and
whether it needs a declared coordinate frame, whether it reads a metadata document (`metadata`) or accepts an envelope (`envelope`), fills `{cursor}` / `{since}` from its own checkpoints,
optionally builds a WebSocket subscription message, and decodes one response or message into one v1
page (or nothing, for a heartbeat). It does no I/O: connections, the address checks, sign-in, limits,
buffering and reconnects are the server's. It must reject, never repair, data that breaks the v1
semantics above, and must be written from the provider's contract documentation, never guessed.
An adapter whose provider serves its dictionary separately declares `metadata: { need, decode }`
(`need` is `required` or `optional`; `decode` checks the document's shape and its binding to the
fixture, and its result reaches the frame decoder as `context.metadata`); one that reads a native
format declares `envelope` and reads `context.envelopeFramesPath` / `context.envelopeCursorPath`, and
may add its own configuration rule (`configProblem`). `context.checkpoint` is the checkpoint the
request or connection resumed from. Native-format adapters share `providers/native.ts`: the envelope
reader, the checkpoint, and the one page builder (a v1 document checked by `decodeFeedObject`).
