# Mario Kart 8 Logger - API Documentation

Describes server version 0.39 (see `package.json`). Changes to the site are announced on
the news page (`/changelog`).

Base URL: `https://mk8u.de/api` (self-hosted: `http://your-server:3000/api`)

## Concepts

- **One person, one entry**: An account owning several Pretendo profiles is counted once
  in the player list, on the player page and in the leaderboards, with its numbers added up.
  See [Players](#players).
- **Console-aware VR uploads**: For an account with several Pretendo profiles, the console
  serial sent by the Aroma plugin decides which profile an upload is recorded for. The
  mapping is learned from the VR history and can be corrected by the user. The sync reads
  follow the same profile. See [Console Assignments](#console-assignments).
- **Multiple Pretendo Profiles**: An account can own several Pretendo player IDs, so
  switching to a different Pretendo profile no longer hides the lobbies, VR points and
  time trial times recorded under the old one. Adding a second profile is an
  admin-controlled per-user permission (`multiPretendoLink`). See
  [Pretendo Profiles](#pretendo-profiles).
- **VR Points System**: Track and display player VR ratings for VS Race and Battle modes
  - Public read access to VR points data
  - Authenticated write access for updating VR points
- **Guest Players**: Full support for local co-op guest players
  - Guest player management and linking to main player accounts
  - Comprehensive nickname history combining Pretendo API and guest names
- **Player search** also finds historical nicknames from both sources
- **Lobbies and races**: a lobby (gathering) comes from Pretendo's server; the single
  races in it (race results) only exist when someone in the lobby races with a current
  MK8Logger plugin. See [Race Results Upload](#race-results-upload).

## Table of Contents

- [Authentication](#authentication)
- [Races](#races)
- [Players](#players)
- [Statistics](#statistics)
- [Server Status](#server-status)
- [Rankings (Time Trials)](#rankings-time-trials)
- [Favorites](#favorites)
- [Poller](#poller)
- [User Settings](#user-settings)
- [VR Points](#vr-points)
- [Token-Based API (v1)](#token-based-api-v1)
- [Guest Players](#guest-players)
- [Error Handling](#error-handling)

---

## Authentication

Some endpoints require authentication via session cookies. To authenticate:

1. **Login**: POST to `/auth/login` with credentials
2. **Session**: The server returns a session cookie
3. **Authenticated Requests**: Include the session cookie in subsequent requests

### API Auth Status

Check API status including database and poller health.

**Endpoint:** `GET /api/auth/status`

**Authentication:** Not required

**Response (Success):**
```json
{
  "status": "ok",
  "timestamp": "2025-01-10T12:00:00.000Z",
  "database": "connected",
  "poller": {
    "isRunning": true,
    "pollCount": 1500,
    "errorCount": 0
  }
}
```

**Response (Error):**
```json
{
  "status": "error",
  "timestamp": "2025-01-10T12:00:00.000Z",
  "database": "disconnected",
  "error": "Connection failed"
}
```

### API Login

Login endpoint for API clients (returns JSON instead of redirecting).

**Endpoint:** `POST /api/auth/login`

**Authentication:** Not required

**Request Body:**
```json
{
  "username": "your_username",
  "password": "your_password"
}
```

**Response (Success):**
```json
{
  "success": true,
  "message": "Login successful",
  "user": {
    "id": 1,
    "username": "your_username",
    "role": "user"
  }
}
```

**Response (Error):**
```json
{
  "success": false,
  "message": "Invalid username or password"
}
```

### Web Login

**Endpoint:** `POST /auth/login`

**Request Body:**
```json
{
  "username": "your_username",
  "password": "your_password"
}
```

**Response (Success):**
```json
{
  "success": true,
  "user": {
    "id": 1,
    "username": "your_username",
    "email": "user@example.com",
    "role": "user"
  }
}
```

**Response (Error):**
```json
{
  "error": "Invalid credentials"
}
```

### Logout

**Endpoint:** `POST /auth/logout`

**Response:**
```json
{
  "success": true
}
```

### Check Authentication Status

**Endpoint:** `GET /auth/status`

**Response (Authenticated):**
```json
{
  "authenticated": true,
  "user": {
    "id": 1,
    "username": "your_username",
    "role": "user"
  }
}
```

**Response (Not Authenticated):**
```json
{
  "authenticated": false
}
```

---

## Races

### Get Active Races

Get all currently active races.

**Endpoint:** `GET /api/races/active`

**Authentication:** Not required

**Response:**
```json
{
  "races": [
    {
      "id": 1,
      "gid": 123456,
      "gameMode": 0,
      "gameModeName": "VS Race",
      "region": "Europe",
      "regionCode": 3,
      "dlcCode": 0,
      "dlcName": "No DLC",
      "isActive": true,
      "firstSeen": "2025-01-10T10:30:00.000Z",
      "lastSeen": "2025-01-10T11:00:00.000Z",
      "racePlayers": [
        {
          "id": 1,
          "playerId": 5,
          "pid": "abc123",
          "miiName": "Player1",
          "isHost": true,
          "isOwner": false,
          "isActive": true,
          "joinedAt": "2025-01-10T10:30:00.000Z",
          "Player": {
            "id": 5,
            "pid": "abc123",
            "miiName": "Player1",
            "totalRaces": 150,
            "totalSessions": 200
          }
        }
      ]
    }
  ]
}
```

### Search Races

Search races with various filters.

**Endpoint:** `GET /api/races/search`

**Authentication:** Not required

**Query Parameters:**
- `gid` (optional): Game ID
- `gameMode` (optional): Game mode (0=VS Race, 1=Battle, 2=Tournament, 3=Friend Room)
- `region` (optional): Region name (Worldwide, Japan, America, Europe, Korea, China, Taiwan)
- `dlc` (optional): DLC code (0=No DLC, 1=DLC 1, 2=DLC 2, 3=DLC 1+2)
- `raceType` (optional): Filter by race type ('real' for 2+ players, 'lobby' for 0-1 players)
- `from` (optional): Start date (ISO format)
- `to` (optional): End date (ISO format)
- `pids` (optional): **NEW** - Comma-separated list of player PIDs to filter races by players
- `concurrent` (optional): **NEW** - Boolean string ('true'/'false'). When 'true' and 2+ PIDs are provided, only returns races where all specified players had overlapping sessions
- `limit` (optional): Results per page (default: 50)
- `offset` (optional): Pagination offset (default: 0)
- `orderColumn` (optional): Column to sort by (default: firstSeen)
- `orderDir` (optional): Sort direction (ASC/DESC, default: DESC)

**Important Notes:**
- **Without parameters**, this endpoint returns only the **first 50 races** (newest first, sorted by `firstSeen` DESC)
- The `total` field shows the total number of races in the database
- To retrieve all races, either:
  - Set a high `limit` value (e.g., `limit=999999`)
  - Use pagination by incrementing `offset` (recommended for large datasets)

**Player Filtering:**
- **Without `concurrent`**: Returns races where **at least one** of the specified players participated
- **With `concurrent=true`**: Returns only races where **all** specified players had overlapping sessions (they were racing at the same time)
- The `concurrent` parameter requires at least 2 player PIDs
- Player filtering works in combination with all other filters (gameMode, region, raceType, etc.)

**Example Requests:**
```
# Get first 50 races (default)
GET /api/races/search

# Get all races (not recommended for large databases)
GET /api/races/search?limit=999999

# Get races with pagination (recommended)
GET /api/races/search?limit=100&offset=0    # First 100 races
GET /api/races/search?limit=100&offset=100  # Next 100 races
GET /api/races/search?limit=100&offset=200  # Next 100 races

# Filter by game mode and region
GET /api/races/search?gameMode=0&region=Europe&limit=20&offset=0

# Filter by race type (real races only)
GET /api/races/search?raceType=real

# Filter by players (races where ANY of these players participated)
GET /api/races/search?pids=abc123,xyz789&limit=50

# Filter by concurrent players (races where ALL players had overlapping sessions)
GET /api/races/search?pids=abc123,xyz789&concurrent=true&limit=50

# Combined filters (real races with specific players in Europe)
GET /api/races/search?pids=abc123,xyz789&gameMode=0&region=Europe&raceType=real
```

**Response:**
```json
{
  "races": [
    {
      "id": 1,
      "gid": 123456,
      "gameMode": 0,
      "gameModeName": "VS Race",
      "region": "Europe",
      "dlcName": "No DLC",
      "isActive": false,
      "firstSeen": "2025-01-10T10:30:00.000Z",
      "lastSeen": "2025-01-10T11:00:00.000Z",
      "playerCount": 8,
      "uniquePlayers": 6,
      "raceDuration": 1800000
    }
  ],
  "total": 1500,
  "limit": 50,
  "offset": 0
}
```

### Search Races with Concurrent Players

Find races where multiple specified players were racing at the same time (overlapping sessions).

**Endpoint:** `GET /api/races/search-concurrent-players`

**Authentication:** Not required

**Query Parameters:**
- `pids` (required): Comma-separated list of player PIDs
- `limit` (optional): Results per page (default: 50)
- `offset` (optional): Pagination offset (default: 0)
- `orderColumn` (optional): Column to sort by (default: firstSeen)
- `orderDir` (optional): Sort direction (ASC/DESC, default: DESC)

**Example Request:**
```
GET /api/races/search-concurrent-players?pids=abc123,xyz789&limit=20
```

**Response:**
```json
{
  "races": [
    {
      "id": 1,
      "gid": 123456,
      "gameModeName": "VS Race",
      "region": "Europe",
      "firstSeen": "2025-01-10T10:30:00.000Z",
      "lastSeen": "2025-01-10T11:00:00.000Z",
      "overlapInfo": {
        "overlapStart": "2025-01-10T10:35:00.000Z",
        "overlapEnd": "2025-01-10T10:50:00.000Z",
        "overlapDurationMs": 900000,
        "overlapDurationFormatted": "15m 0s"
      }
    }
  ],
  "total": 15,
  "limit": 20,
  "offset": 0
}
```

### Get Race Details

Get detailed information about a specific race.

**Endpoint:** `GET /api/races/:id`

**Authentication:** Not required

**Parameters:**
- `id`: Race ID

**Response:**
```json
{
  "id": 1,
  "gid": 123456,
  "gameMode": 0,
  "gameModeName": "VS Race",
  "region": "Europe",
  "dlcCode": 0,
  "dlcName": "No DLC",
  "isActive": false,
  "firstSeen": "2025-01-10T10:30:00.000Z",
  "lastSeen": "2025-01-10T11:00:00.000Z",
  "racePlayers": [
    {
      "id": 1,
      "playerId": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "joinedAt": "2025-01-10T10:30:00.000Z",
      "leftAt": "2025-01-10T11:00:00.000Z",
      "Player": {
        "id": 5,
        "pid": "abc123",
        "miiName": "Player1",
        "totalRaces": 150
      }
    }
  ]
}
```

### Get Race Statistics

Get detailed statistics for a specific race.

**Endpoint:** `GET /api/races/:id/stats`

**Authentication:** Not required

**Parameters:**
- `id`: Race ID

**Response:**
```json
{
  "race": {
    "id": 1,
    "gid": 123456,
    "gameMode": 0,
    "gameModeName": "VS Race",
    "region": "Europe",
    "dlcName": "No DLC",
    "isActive": false,
    "firstSeen": "2025-01-10T10:30:00.000Z",
    "lastSeen": "2025-01-10T11:00:00.000Z"
  },
  "statistics": {
    "totalSessions": 15,
    "uniquePlayers": 12,
    "minConcurrentPlayers": 2,
    "maxConcurrentPlayers": 8,
    "avgConcurrentPlayers": 5.5,
    "raceDurationMs": 1800000,
    "raceDurationFormatted": "30m 0s"
  },
  "sessionTimeline": [
    {
      "playerId": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "joinedAt": "2025-01-10T10:30:00.000Z",
      "leftAt": "2025-01-10T11:00:00.000Z",
      "durationMs": 1800000,
      "durationFormatted": "30m 0s"
    }
  ]
}
```

### Get Race Sessions

Get all player sessions for a specific race.

**Endpoint:** `GET /api/races/:id/sessions`

**Authentication:** Not required

**Parameters:**
- `id`: Race ID (numeric database ID, not gid)

**Response:**
```json
{
  "race": {
    "gid": 123456,
    "gameModeName": "VS Race",
    "firstSeen": "2025-01-10T10:30:00.000Z",
    "lastSeen": "2025-01-10T11:00:00.000Z",
    "isActive": false
  },
  "sessions": [
    {
      "playerId": 5,
      "playerPid": "abc123",
      "miiName": "Player1",
      "joinedAt": "2025-01-10T10:30:00.000Z",
      "leftAt": "2025-01-10T11:00:00.000Z",
      "isActive": false,
      "isHost": true,
      "isOwner": false,
      "durationMs": 1800000,
      "durationFormatted": "30m 0s"
    }
  ]
}
```

### Mii Image

**Endpoint:** `GET /api/mii/:hash.png`

**Authentication:** Not required

The face of a Mii reported with a race result (`miiHash`), 270×270 PNG. It is rendered once by
Nintendo's Mii Studio renderer from the stored Mii data and then served from mk8u.de's own cache
(`Cache-Control: immutable` - the same hash always shows the same Mii). `404` for unknown hashes
or when rendering failed recently (retried after an hour).

### Get Race Rounds

Get the single races of a lobby, as uploaded by the Aroma plugin
(see [Race Results Upload](#race-results-upload)).

**Endpoint:** `GET /api/races/:id/rounds`

**Authentication:** Not required

**Query Parameters:**
- `limit` (optional): 1-200, default 50
- `offset` (optional): default 0

**Response:**
```json
{
  "raceId": 4711,
  "current": { "courseId": 63, "courseName": "Big Blue", "startedAt": "2026-09-25T14:47:02.000Z" },
  "limit": 50,
  "offset": 0,
  "rounds": [
    {
      "id": 17,
      "raceId": 4711,
      "finishedAt": "2026-09-25T14:39:41.000Z",
      "gameMode": 0,
      "courseId": 27,
      "courseName": "Mario Kart Stadium",
      "playerCount": 12,
      "uploadCount": 2,
      "players": [
        { "rank": 1, "points": 15, "miiName": "Kurt", "playerId": 88, "pid": "1471493879",
          "matchStatus": "name", "vr": 48477, "vrChange": 22, "vrExact": 48477.02,
          "vrBeforeExact": 48455.61, "countryId": 49, "regionId": 50, "isGuest": false }
      ]
    }
  ]
}
```

Rounds are ordered newest first, players by placement. `current` is the course being raced
right now (reported by [Race Start](#race-start)), or `null`.

---

### Find Lobbies by Player Names

Recent lobbies in which at least two of the given names took part. It was used by the
screenshot recognition of the VR entry, which has been removed; the website no longer
calls it.

**Endpoint:** `GET /api/races/match-by-names`

**Authentication:** Not required

**Query Parameters:**
- `names` (required): comma-separated Mii names (partial match), at least two
- `timeWindow` (optional): look back this many days (default: 7)
- `limit` (optional): number of lobbies (default: 10)

**Response:**
```json
{
  "matches": [
    { "id": 110535, "gid": 4431123, "gameModeName": "VS Race", "region": "Worldwide",
      "firstSeen": "…", "lastSeen": "…", "racePlayers": [ … ],
      "matchScore": 3,
      "matchingPlayers": [ { "pid": 1311404083, "miiName": "Achim" } ] }
  ],
  "total": 1,
  "searchedNames": ["Achim", "Dmi", "alex"],
  "foundPlayers": [ { "pid": 1311404083, "miiName": "Achim" } ]
}
```

## Players

**One person, one entry.** Every recording keys off a Pretendo player ID, so an account
that owns several profiles would otherwise appear once per profile, with its numbers split
across the entries. Wherever players are counted, listed or ranked, the profiles of an
account are folded into its **primary** one and their numbers added up:

- `GET /api/players/search` — one row per person, `profileCount` says how many profiles it
  covers. Searching by an old Mii name or an old PID still finds the merged entry. Pass
  `profiles=1` to get the individual profiles back instead, which is what a picker needs.
- `GET /api/players/:id` and `GET /api/players/:id/stats` — reachable through any of the
  profiles and giving the same answer, with `profiles` listing what the numbers are made
  of. Co-players are merged on both sides, and an account's own profiles are not
  co-players of each other.
- `GET /api/vr-points/:playerId/latest` — the current rating and the personal best span
  the account. Each value carries a `player` naming the profile it was recorded for, since
  VR does not carry over between profiles. The top-level `player` stays the profile that
  was requested, because that is what an edit writes to.
- `GET /api/rankings/player/:playerId` — best time per track across the account: after a
  switch the fastest time on a track may sit under either profile.
- `GET /api/stats`, `GET /api/stats/summary`, `GET /api/stats/leaderboards`,
  `GET /api/stats/vr-points-leaderboards` — the person appears once, with the combined
  values, and the player count counts people rather than profiles.
- `GET /api/stats/social/*` and `GET /api/stats/network` — duos, trios, the connection
  matrix and the graph pair people rather than profiles, so nobody is paired with
  themselves and there is no edge between someone and their own former profile.
- `GET /api/stats/records`, `/api/stats/growth`, `/api/stats/engagement` and
  `/api/stats/leaderboards/stacked-area` — per person as well. Note that a profile switch
  is therefore **not** counted as a new player in the growth curve.

Lobbies themselves are **not** merged: a race still lists the profile that actually took
part. The archive records what happened, and rewriting it would misstate who was in the
room.

### Get Race Result Statistics

Most played courses, drivers and driver/vehicle combinations from race results (statistics
page, tab "Tracks & drivers").

**Endpoint:** `GET /api/stats/race-results?days=7`

**Authentication:** None

**Query:** `days` (optional; without it: all time)

**Response:**
```json
{ "races": 120,
  "courses": [ { "courseId": 23, "races": 9 } ],
  "drivers": [ { "driverId": 12, "uses": 40 } ],
  "combos": [ { "driverId": 12, "bodyId": 12, "tireId": 2, "gliderId": 9, "uses": 30 } ] }
```

### Get Recent Race Rounds

The latest races of all lobbies, newest first (start page).

**Endpoint:** `GET /api/race-rounds/recent?limit=10`

**Authentication:** None

**Query:** `limit` 1-50 (default 10)

**Response:** `{ "rounds": [ …as in Get Race Rounds… ] }`

### Get VR Movers

Players with the biggest VR gain from race results in the last hours, best first. Only
matched players count (no guests, no unknown names).

**Endpoint:** `GET /api/race-rounds/movers?hours=24&limit=5`

**Authentication:** None

**Query:** `hours` 1-168 (default 24), `limit` 1-20 (default 5)

**Response:**
```json
{ "hours": 24, "movers": [ { "playerId": 385, "miiName": "Achim", "miiHash": "…", "countryId": 78, "gain": 102, "races": 12 } ] }
```

### Get Race Round

One race (RaceRound) with its lobby and its position among the lobby's races.
Used by the race page (`/races/:id`).

**Endpoint:** `GET /api/race-rounds/:id`

**Authentication:** None

**Response:**
```json
{
  "round": { "id": 812, "raceId": 110312, "finishedAt": "2026-09-26T15:58:00.000Z", "courseId": 63,
             "courseName": "Big Blue", "playerCount": 8, "uploadCount": 1, "players": [ "…as in Get Race Rounds…" ] },
  "lobby": { "id": 110312, "gid": 14210, "gameModeName": "VS Race", "region": "Worldwide",
             "dlcName": "DLC 1+2", "isActive": false, "firstSeen": "…", "lastSeen": "…" },
  "position": { "number": 12, "total": 12, "previousId": 809, "nextId": null }
}
```

`lobby` and `position` are `null` for a race that belongs to no lobby.

**Errors:** `404` if the race does not exist.

### Search Players

Search for players by name or PID.

**Endpoint:** `GET /api/players/search`

**Authentication:** Not required

**Query Parameters:**
- `miiName` (optional): Search by player name (partial match, case-insensitive)
- `pid` (optional): Search by exact player PID
- `limit` (optional): Results per page (default: 50)
- `offset` (optional): Pagination offset (default: 0)
- `orderColumn` (optional): Column index to sort by (0=miiName, 1=pid, 2=totalRaces, 3=totalSessions, 4=firstSeen, 5=lastSeen, 6=totalPlaytime, default: lastSeen)
- `orderDir` (optional): Sort direction (ASC/DESC, default: DESC)
- `profiles` (optional): `1` returns one row per **Pretendo profile** instead of one per person

**Merged or per profile**

By default the profiles of an account are folded into one row — the player list is a list
of people. Use `profiles=1` wherever the caller has to pick a *specific* Pretendo player
ID, because the thing being picked belongs to one profile: entering a time, resolving a
PID for a VR comparison. Without it, searching for an old Mii name hands back the primary
profile's ID and the pick silently lands on the wrong player.

| | default | `profiles=1` |
|---|---|---|
| Rows | one per person | one per Pretendo profile |
| `id` / `pid` | the primary profile of the account | the profile itself |
| `totalRaces` etc. | summed across profiles | that profile's own |
| `profileCount` | number of profiles merged | always `1` |
| `total` | number of people | number of profiles |

Both modes return `accountPid` / `accountMiiName`, naming the account a profile belongs
to. In merged mode they equal the row itself; with `profiles=1` they differ for a profile
someone switched away from, which is how a picker can label it.

**Important Notes:**
- **Without parameters**, this endpoint returns only the **first 50 players** (newest first, sorted by `lastSeen` DESC)
- The `total` field shows the total number of players matching the search criteria
- `totalPlaytimeSeconds` is calculated from all completed sessions (where `leftAt` is not null)
- Search also includes historical nicknames from both Pretendo API and guest player entries. Searching for a guest nickname will find the associated main player.
- To retrieve all players, either:
  - Set a high `limit` value (e.g., `limit=999999`)
  - Use pagination by incrementing `offset` (recommended for large datasets)

**Example Requests:**
```
# Get first 50 players (default)
GET /api/players/search

# Search by player name (partial match)
GET /api/players/search?miiName=Player1&limit=10

# Search by exact PID
GET /api/players/search?pid=abc123

# Get all players sorted by total playtime
GET /api/players/search?orderColumn=6&orderDir=DESC&limit=100

# Pagination
GET /api/players/search?limit=100&offset=0    # First 100 players
GET /api/players/search?limit=100&offset=100  # Next 100 players
```

**Response:**
```json
{
  "players": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "firstSeen": "2025-01-01T10:00:00.000Z",
      "lastSeen": "2025-01-10T11:00:00.000Z",
      "totalRaces": 150,
      "totalSessions": 200,
      "totalPlaytimeSeconds": 180000
    }
  ],
  "total": 500,
  "limit": 50,
  "offset": 0
}
```

### Get Player Details

Get basic information about a specific player.

**Endpoint:** `GET /api/players/:id`

**Authentication:** Not required

**Parameters:**
- `id`: Player ID

**Response:**
```json
{
  "player": {
    "id": 5,
    "pid": "abc123",
    "miiName": "Player1",
    "firstSeen": "2025-01-01T10:00:00.000Z",
    "lastSeen": "2025-01-10T11:00:00.000Z",
    "totalRaces": 150,
    "totalSessions": 200,
    "playerRaces": [
      {
        "id": 1,
        "raceId": 1,
        "joinedAt": "2025-01-10T10:30:00.000Z",
        "leftAt": "2025-01-10T11:00:00.000Z",
        "Race": {
          "id": 1,
          "gid": 123456,
          "gameModeName": "VS Race"
        }
      }
    ]
  }
}
```

### Get Player Statistics

Get detailed statistics for a specific player.

**Endpoint:** `GET /api/players/:id/stats`

**Authentication:** Not required

**Parameters:**
- `id`: Player ID

**Query Parameters:**
- `period` (optional): Activity timeline grouping - `day` (default), `week`, or `month`

**Example Requests:**
```
# Get daily activity stats (default)
GET /api/players/5/stats

# Get weekly activity stats
GET /api/players/5/stats?period=week

# Get monthly activity stats
GET /api/players/5/stats?period=month
```

**Response:**
```json
{
  "player": {
    "id": 5,
    "pid": "abc123",
    "miiName": "Player1",
    "firstSeen": "2025-01-01T10:00:00.000Z",
    "lastSeen": "2025-01-10T11:00:00.000Z",
    "totalRaces": 150,
    "totalSessions": 200
  },
  "statistics": {
    "totalDays": 10,
    "totalWeeks": 2,
    "totalMonths": 1,
    "avgSessionsPerDay": 20.0,
    "avgSessionsPerWeek": 100.0,
    "avgSessionsPerMonth": 200.0,
    "avgRacesPerDay": 15.0,
    "avgRacesPerWeek": 75.0,
    "avgRacesPerMonth": 150.0,
    "totalPlaytimeSeconds": 180000,
    "totalPlaytimeRank": 5,
    "avgPlaytimePerSession": 900,
    "avgPlaytimePerRace": 1200,
    "avgPlaytimePerDay": 18000,
    "validRacingSessions": 120,
    "validRaces": 100,
    "validPlaytimeSeconds": 150000,
    "validPlaytimeRank": 3,
    "avgValidPlaytimePerSession": 1250
  },
  "nameHistory": [
    {
      "miiName": "Player1",
      "firstUsed": "2025-01-01T10:00:00.000Z",
      "lastUsed": "2025-01-10T11:00:00.000Z"
    }
  ],
  "longestSession": {
    "sessionId": 42,
    "raceId": 15,
    "gid": 123456,
    "joinedAt": "2025-01-05T14:00:00.000Z",
    "effectiveLeftAt": "2025-01-05T18:30:00.000Z",
    "durationMinutes": 270
  },
  "longestDay": {
    "date": "2025-01-07",
    "totalMinutes": 420,
    "sessionCount": 8,
    "raceCount": 6
  },
  "activityTimeline": [
    {
      "date": "2025-01-10",
      "sessionCount": 5,
      "raceCount": 4,
      "playtimeMinutes": 120
    }
  ],
  "activityPeriod": "day",
  "favoriteGameMode": {
    "gameMode": 0,
    "gameModeName": "VS Race",
    "raceCount": 100
  },
  "favoriteDLC": {
    "dlcCode": 0,
    "dlcName": "No DLC",
    "raceCount": 120
  },
  "favoriteDayOfWeek": {
    "dayOfWeek": 6,
    "sessionCount": 50
  },
  "weekdayActivity": [
    {
      "dayOfWeek": 0,
      "sessionCount": 20
    },
    {
      "dayOfWeek": 6,
      "sessionCount": 50
    }
  ],
  "topCoPlayers": [
    {
      "id": 10,
      "pid": "xyz789",
      "miiName": "Player2",
      "racesTogether": 80
    }
  ],
  "topConcurrentCoPlayers": [
    {
      "id": 11,
      "pid": "def456",
      "miiName": "Player3",
      "concurrentRaces": 45,
      "totalOverlapMinutes": 350
    }
  ],
  "recentRaces": [
    {
      "id": 1,
      "gid": 123456,
      "gameMode": 0,
      "gameModeName": "VS Race",
      "region": "Europe",
      "dlcName": "No DLC",
      "isActive": false,
      "joinedAt": "2025-01-10T10:30:00.000Z",
      "leftAt": "2025-01-10T11:00:00.000Z",
      "sessionDuration": 1800000
    }
  ]
}
```

**Further fields:**
- `nameHistory`: Array of all names the player has used with timestamps
- `longestSession`: Details of the player's longest single gaming session
- `longestDay`: Details of the day with the most total playtime
- `activityPeriod`: Current grouping period for activity timeline (day/week/month)
- Players with the most overlapping playtime are a separate request, see
  [Get Concurrent Co-Players](#get-concurrent-co-players)

- `nicknameHistory`: Comprehensive nickname history from both Pretendo API and guest player entries, with source tracking ("pretendo" or "guest"), timestamps, and usage counts

**Note on Valid Racing Sessions:**

The statistics include both total counts and "valid racing" metrics:

- **Valid Racing Sessions**: Sessions where the player was in a race with at least 2 players (not solo) AND the session lasted at least 2 minutes (120 seconds). These represent actual races where meaningful gameplay occurred.
- **Total Sessions/Races**: All sessions/races including solo sessions and very short sessions.

The `validRacingSessions`, `validRaces`, `validPlaytimeSeconds`, and `avgValidPlaytimePerSession` fields help distinguish between actual racing activity and lobby idle time or solo sessions.

**Playtime Rankings:**

The statistics also include ranking information:

- **totalPlaytimeRank**: The player's ranking based on total playtime (all sessions). Lower numbers are better (1 = highest playtime).
- **validPlaytimeRank**: The player's ranking based on valid racing playtime only. Lower numbers are better (1 = highest valid playtime).

### Get Player Race Summary

What the race results reported by the Aroma plugin say about one person, across all
Pretendo profiles of the account. Used by the player page (`/players/:id`).

**Endpoint:** `GET /api/players/:id/race-summary`

**Authentication:** None

**Response:**
```json
{
  "playerId": 385,
  "totals": { "races": 120, "wins": 31, "podiums": 58, "avgRank": 3.9, "limitedTo": null },
  "vr": { "current": 8802, "change7d": 197, "updatedAt": "2026-09-26T15:58:00.000Z",
          "max": 9137, "maxAt": "2026-09-12T19:03:00.000Z" },
  "recent": [
    { "roundId": 812, "raceId": 110312, "finishedAt": "2026-09-26T15:58:00.000Z", "courseId": 63,
      "gameMode": 0, "playerCount": 8, "rank": 6, "points": 7, "vr": 8738, "vrChange": -9,
      "isGuest": false, "driverId": 12, "bodyId": 12, "tireId": 2, "gliderId": 9 }
  ],
  "tracks": [
    { "courseId": 23, "races": 9, "wins": 2, "avgRank": 3.4, "vrBalance": 41 }
  ],
  "opponents": [
    { "playerId": 9723, "miiName": "Dmi", "miiHash": "…", "countryId": 110, "racesTogether": 14, "ahead": 5 }
  ]
}
```

- `totals`, `tracks` and `opponents` cover the latest 1000 races; `limitedTo` is 1000 when
  there are more, otherwise `null`.
- `vr` comes from all VR entries (VS race): `max` is the highest value, `maxAt` when it was
  first reached.
- `recent` holds the latest 30 races, newest first. `raceId` is the lobby or `null`.
- `tracks`: per course, most raced first; `vrBalance` is the sum of the VR changes.
- `opponents`: the 10 matched players seen most often in the same races; `ahead` counts the
  races this player finished in front of them.
- `vr`: latest VS race VR from all sources and the change against the last value at least 7
  days old (`null` without such a value).

**Errors:** `404` if the player does not exist.

### Get Player Sessions

**✅ Returns session start times (joinedAt) for all player sessions.**

Get all sessions for a specific player (by PID). This endpoint provides the complete session history including when each session started.

**Endpoint:** `GET /api/players/:pid/sessions`

**Authentication:** Not required

**Parameters:**
- `pid`: Player PID

**Response:**
```json
{
  "player": {
    "pid": "abc123",
    "miiName": "Player1",
    "totalRaces": 150
  },
  "sessions": [
    {
      "raceId": 1,
      "raceGid": 123456,
      "gameModeName": "VS Race",
      "joinedAt": "2025-01-10T10:30:00.000Z",
      "leftAt": "2025-01-10T11:00:00.000Z",
      "isActive": false,
      "isHost": true,
      "isOwner": false,
      "durationMs": 1800000,
      "durationFormatted": "30m 0s"
    }
  ]
}
```

**Key Fields:**
- `joinedAt`: **Session start time** - timestamp when the player joined the race
- `leftAt`: Session end time - timestamp when the player left (null if still active)
- `isActive`: Whether the session is currently active
- `durationMs`: Session duration in milliseconds
- `durationFormatted`: Human-readable session duration

---

### Get Player by PID

Look up a player by their Pretendo player ID (PID) instead of the internal ID.

**Endpoint:** `GET /api/players/by-pid/:pid`

**Authentication:** Not required

**Response:**
```json
{
  "player": {
    "id": 5,
    "pid": 1311404083,
    "miiName": "Achim",
    "firstSeen": "2025-11-30T23:43:42.000Z",
    "lastSeen": "2026-09-27T20:11:02.000Z",
    "totalRaces": 412,
    "totalSessions": 530,
    "countryId": 78,
    "regionId": 2,
    "miiHash": "3f9c0b…"
  }
}
```

`404` when no player has this PID.

### Get Concurrent Co-Players

The five people who were most often in the same lobby **at the same time** as this
player (overlapping sessions, not just the same lobby). Counted per person on both
sides, so a profile switch does not split anyone.

**Endpoint:** `GET /api/players/:id/concurrent-coplayers`

**Authentication:** Not required

**Response:**
```json
{
  "topConcurrentCoPlayers": [
    { "id": 12, "pid": 1356763971, "miiName": "Dmi", "concurrentRaces": 38 },
    { "id": 44, "pid": 1043681158, "miiName": "alex_rf", "concurrentRaces": 21 }
  ]
}
```

`concurrentRaces` is the number of lobbies with overlapping sessions. This query walks
all shared lobbies and can take a few seconds for very active players.

### Get Linked Player IDs

All Pretendo PIDs that are linked to any account on the site. The web pages use it to
mark players who have an account.

**Endpoint:** `GET /api/linked-pids`

**Authentication:** Not required

**Response:**
```json
{ "linkedPids": ["1311404083", "1356763971"] }
```

On an error the list is empty rather than a `500`.

## Statistics

All statistics endpoints now support time filtering via the `days` query parameter for better temporal analysis.

### Get Dashboard Counters

The three counters the dashboard tiles show. `GET /api/stats` answers them as
well, but only after aggregating everything the statistics page needs, which
takes seconds on a large archive — this endpoint runs three counts and nothing
else, and caches them for 15 seconds.

**Endpoint:** `GET /api/stats/summary`

**Authentication:** Not required

**Query Parameters:** none

**Response:**
```json
{
  "totalRaces": 37500,
  "activeRaces": 3,
  "totalPlayers": 20000
}
```

`totalPlayers` counts people rather than profiles: an account that has linked
several Pretendo profiles counts once, the same as everywhere else.

### Get Global Statistics

Get global statistics and leaderboards.

**Endpoint:** `GET /api/stats`

**Authentication:** Not required

**Query Parameters:**
- `limit` (optional): Number of results per category (default: 10)
- `days` (optional): Time range filter - number of days to include, or 'all' for all-time stats (default: all)
- `startDate` (optional): Alternative to `days` - start date in ISO format
- `endDate` (optional): End date in ISO format (used with startDate)

**Example Requests:**
```
# Get all-time stats
GET /api/stats

# Get stats for last 7 days
GET /api/stats?days=7

# Get stats for last 30 days
GET /api/stats?days=30

# Get stats for specific date range
GET /api/stats?startDate=2025-01-01&endDate=2025-01-31
```

**Response:**
```json
{
  "totalRaces": 1500,
  "totalPlayers": 500,
  "activePlayers": 50,
  "totalSessions": 5000,
  "topPlayersByRaces": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "totalRaces": 150,
      "totalSessions": 200,
      "lastSeen": "2025-01-10T11:00:00.000Z"
    }
  ],
  "topPlayersBySessions": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "totalRaces": 150,
      "totalSessions": 200,
      "lastSeen": "2025-01-10T11:00:00.000Z"
    }
  ],
  "topPlayersByPlaytime": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "totalRaces": 150,
      "totalSessions": 200,
      "totalPlaytimeSeconds": 180000,
      "lastSeen": "2025-01-10T11:00:00.000Z"
    }
  ],
  "topPlayersByAvgPlaytimePerDay": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "totalRaces": 150,
      "totalSessions": 200,
      "avgPlaytimePerDaySeconds": 18000,
      "lastSeen": "2025-01-10T11:00:00.000Z"
    }
  ],
  "recentPlayers": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "totalRaces": 150,
      "totalSessions": 200,
      "lastSeen": "2025-01-10T11:00:00.000Z"
    }
  ],
  "activityHeatmap": [
    {
      "hour": 14,
      "dayOfWeek": 6,
      "sessionCount": 50
    }
  ],
  "gameModeDistribution": [
    {
      "gameMode": 0,
      "gameModeName": "VS Race",
      "count": 800
    }
  ],
  "regionDistribution": [
    {
      "region": "Europe",
      "count": 500
    }
  ],
  "dlcDistribution": [
    {
      "dlcCode": 0,
      "dlcName": "No DLC",
      "count": 1000
    }
  ]
}
```

**Note:** the concurrent-player figures used to be part of this response under
`concurrentPlayers`. They now live on `GET /api/stats/concurrency` — see below.

### Get Concurrent Players

How many people were in a lobby at the same time: the peak, the average, an
hourly curve, and the weekday/hour grids the heatmap draws.

This is the one figure on the statistics page that has to walk every single
session, so it has its own endpoint — an archive with 300 000 sessions takes a
few seconds here while everything else is aggregated by the database in a
fraction of that. The page fetches it alongside `GET /api/stats` and draws the
chart when it arrives, instead of holding the whole page back.

**Endpoint:** `GET /api/stats/concurrency`

**Authentication:** Not required

**Query Parameters:** the same time range as `GET /api/stats` — `days`,
or `startDate`/`endDate`.

**Response:**
```json
{
  "concurrentPlayers": {
    "max": 90,
    "min": 1,
    "avg": 12.34,
    "timeline": [
      { "timestamp": "2026-08-11T12:00:00.000Z", "maxConcurrent": 42 }
    ],
    "byHour": [ { "hourOfDay": 0, "maxConcurrent": 12 } ],
    "byDayOfWeek": [ { "dayOfWeek": 0, "maxConcurrent": 30 } ],
    "heatmap": [ { "dayOfWeek": 0, "hourOfDay": 0, "maxConcurrent": 8.5 } ]
  }
}
```

`max`, `byHour` and `byDayOfWeek` are peaks; `heatmap` averages each weekday and
hour over the weeks it saw. A person in two lobbies at once counts once, and
a session that was never closed keeps counting.

---

### Get Top Duos

The ten pairs of players with the most time together in lobbies (overlapping sessions),
chosen among the 100 players with the most playtime. Pairs people, not profiles.

**Endpoint:** `GET /api/stats/social/duos`

**Authentication:** Not required

**Query Parameters:**
- `days` (optional): only lobbies of the last N days (default: all)

**Response:**
```json
[
  {
    "player1Id": 5, "player1Name": "Achim", "player1Pid": 1311404083,
    "player2Id": 12, "player2Name": "Dmi", "player2Pid": 1356763971,
    "totalOverlapMinutes": 1840,
    "racesTogether": 64,
    "lastRaceTogether": "2026-09-27T19:02:11.000Z",
    "avgSessionDurationMinutes": 71.4
  }
]
```

`racesTogether` counts lobbies, `lastRaceTogether` is the start of the latest shared lobby.
Results are cached for a few minutes.

### Get Top Trios

Like the duos, for three people who were in a lobby at the same time.

**Endpoint:** `GET /api/stats/social/trios`

**Authentication:** Not required

**Query Parameters:**
- `days` (optional): only lobbies of the last N days (default: all)

**Response:**
```json
[
  {
    "player1Id": 5, "player1Name": "Achim", "player1Pid": 1311404083,
    "player2Id": 12, "player2Name": "Dmi", "player2Pid": 1356763971,
    "player3Id": 44, "player3Name": "alex_rf", "player3Pid": 1043681158,
    "totalOverlapMinutes": 610,
    "racesTogether": 19,
    "lastRaceTogether": "2026-09-26T21:34:30.000Z"
  }
]
```

### Get Average Co-Players

How many different people a player met on average at the same time in a lobby.

**Endpoint:** `GET /api/stats/social/avg-co-players`

**Authentication:** Not required

**Query Parameters:**
- `days` (optional): only lobbies of the last N days (default: all)

**Response:**
```json
{ "avgCoPlayers": 23.7 }
```

### Get Connection Matrix

Time together for every pair among the ten people with the most lobbies.

**Endpoint:** `GET /api/stats/social/connection-matrix`

**Authentication:** Not required

**Query Parameters:**
- `days` (optional): only lobbies of the last N days for the connections (default: all);
  the ten players are always chosen over all time

**Response:**
```json
{
  "players": [
    { "id": 5, "pid": 1311404083, "miiName": "Achim", "totalRaces": 412 }
  ],
  "connections": [
    { "player1Id": 5, "player2Id": 12, "overlapMinutes": 1840, "sharedRaces": 64 }
  ]
}
```

A pair without shared time has no entry in `connections`. `sharedRaces` counts lobbies.

### Get Leaderboard Shares Over Time

Data for the "shares" chart of a leaderboard: the value of each top player per day, week
or month, plus everybody else and the total.

**Endpoint:** `GET /api/stats/leaderboards/stacked-area`

**Authentication:** Not required

**Query Parameters:**
- `type` (optional): `gatherings` (lobbies, default), `sessions` or `playtime` (minutes)
- `days` (optional): last N days (default: all)
- `topN` (optional): number of players with their own series (default: 10)

The bucket size follows the period: up to 31 days per day, up to 180 days per week,
otherwise per month.

**Response:**
```json
{
  "type": "gatherings",
  "bucketType": "day",
  "buckets": ["2026-09-25", "2026-09-26", "2026-09-27"],
  "series": [
    { "id": 5, "miiName": "Achim", "pid": 1311404083, "data": [3, 5, 2] }
  ],
  "other": [40, 52, 31],
  "totals": [43, 57, 33]
}
```

`week` buckets look like `2026-W39`, `month` buckets like `2026-09`.

### Get Players With the Most Names

Players known under more than one name - Mii names seen on Pretendo and names they
were entered under as a guest - with every name.

**Endpoint:** `GET /api/statistics/most-nicknames`

**Authentication:** Not required

**Query Parameters:**
- `limit` (optional): number of players (default: 50)

**Response:**
```json
{
  "players": [
    {
      "playerId": 12,
      "pid": 1356763971,
      "currentName": "Dmi",
      "firstSeen": "2026-06-19T10:12:00.000Z",
      "lastSeen": "2026-09-27T20:11:02.000Z",
      "totalRaces": 188,
      "nicknameCount": 3,
      "allNicknames": [
        { "nickname": "Dmi", "source": "pretendo", "firstUsed": "…", "lastUsed": "…", "totalCount": 170 },
        { "nickname": "Dima", "source": "guest", "firstUsed": "…", "lastUsed": "…", "totalCount": 4 }
      ]
    }
  ]
}
```

`source` is `pretendo` or `guest`; `totalCount` counts sessions with that name.

## Server Status

### Get Race Logging Coverage

How much of the time spent in lobbies is covered by races logged through the
MK8Logger plugin. Counted from the first race whose start was reported
(`POST /api/v1/race-start`) - races before that have no known duration.

**Endpoint:** `GET /api/server-status/race-logging`

**Authentication:** Not required

**Response:**
```json
{
  "since": "2026-09-27T18:04:10.000Z",
  "races": 412,
  "timedRaces": 398,
  "lobbies": 57,
  "lobbySeconds": 186420,
  "racedSeconds": 61230,
  "racedShare": 32.8
}
```

- `since`: start of the period (first race with a stored start); `null` when no race has one yet - then the other fields are missing
- `races`: races logged since then; `timedRaces`: those with a known start
- `lobbySeconds`: time in which at least two players (guests included) were in a lobby at once, within the period, added up over all lobbies (running lobbies until now)
- `lobbies`: lobbies that had at least two players at once in the period
- `racedSeconds`: start to result of every timed race, added up
- `racedShare`: `racedSeconds` as a percentage of `lobbySeconds` (one decimal), `null` without lobby time

A reported start counts for a result only when the course matches and the race
took between 45 seconds and 8 minutes.

### Get Server Downtime Statistics

Get server uptime/downtime statistics based on race logging activity. Downtime is calculated only during periods when the poller was active (based on race logs and poller errors).

**Endpoint:** `GET /api/server-status/downtime`

**Authentication:** Not required

**Query Parameters:**
- `from` (optional): Start date (ISO format, default: 2025-10-29T00:00:00)
- `to` (optional): End date (ISO format, default: now)

**Example Request:**
```
GET /api/server-status/downtime?from=2025-10-29T00:00:00&to=2025-11-19T23:59:59
```

**Response:**
```json
{
  "startDate": "2025-10-29T00:00:00.000Z",
  "endDate": "2025-11-19T23:59:59.999Z",
  "pollerActiveFrom": "2025-10-29T08:00:00.000Z",
  "pollerActiveTo": "2025-11-19T20:00:00.000Z",
  "totalDowntimeMs": 3600000,
  "totalDowntimeFormatted": "1h 0m",
  "uptimePercentage": "99.50",
  "incidentCount": 2,
  "incidents": [
    {
      "startTime": "2025-11-10T14:00:00.000Z",
      "endTime": "2025-11-10T14:35:00.000Z",
      "durationMs": 2100000,
      "durationFormatted": "35m 0s"
    },
    {
      "startTime": "2025-11-15T03:00:00.000Z",
      "endTime": "2025-11-15T04:25:00.000Z",
      "durationMs": 5100000,
      "durationFormatted": "1h 25m"
    }
  ],
  "racesLogged": 15000,
  "pollerEvents": 15250,
  "note": "Downtime calculated only during poller-active period (based on races and errors). Gaps of 60+ minutes count as downtime."
}
```

**Key Fields:**
- `pollerActiveFrom/To`: Time range when poller was confirmed active (first to last race/error)
- `uptimePercentage`: Percentage of time with active races during monitored period
- `totalDowntimeMs`: Total milliseconds of downtime (gaps >= 60 minutes without races)
- `incidents`: Array of downtime incidents with exact start/end times
- `pollerEvents`: Total number of poller activity indicators (races + errors)

**Downtime Calculation:**
- Downtime is only counted during periods when the poller was actively monitoring
- A gap of 60+ minutes between races is considered a downtime incident
- Times before the first poller event or after the last are not counted as downtime
- The default monitoring period starts from October 29, 2025

---

### Get Server Status History

The raw status checks of the Pretendo server components, newest first.

**Endpoint:** `GET /api/server-status/history`

**Authentication:** Not required

**Query Parameters:**
- `componentId` (optional): one component by its ID
- `componentName` (optional): components whose name contains this text (ignored when
  `componentId` is given)
- `from`, `to` (optional): time range of the checks (ISO 8601)
- `limit` (optional): number of entries (default: 100)

**Response:**
```json
{
  "history": [
    {
      "id": 9811,
      "componentId": "<Pretendo component ID>",
      "componentName": "Mario Kart 8",
      "status": "operational",
      "previousStatus": "partial_outage",
      "checkedAt": "2026-09-27T20:10:00.000Z"
    }
  ]
}
```

`status` and `previousStatus`: `operational`, `degraded_performance`, `partial_outage`
or `major_outage`, as Pretendo's status page reports them.

## Rankings (Time Trials)

Time trial toplists ("Toplisten") for all 48 courses. The data is **not** stored in
the logger database - it is fetched live from the public Pretendo MK8 website API
(`https://mario-kart-8.pretendo.network/api/rankings/:trackId`, which proxies the
AMKJ gRPC call `GetTimeTrialRanking`) and normalised:

- the raw `score` (milliseconds) is formatted as `m:ss.mmm`
- the Mii name is decoded from the NEX `commonData` blob (UTF-16LE at offset `0x2E`)
- every entry is cross-referenced with the local `players` table, so ranked players
  that this logger already knows are returned with their local player record

Responses are cached server-side to keep load off the Pretendo backend
(configurable via `MK8_RANKINGS_CACHE_TTL_MS` / `MK8_RANKINGS_OVERVIEW_CACHE_TTL_MS`).

**Note:** All ranking endpoints require authentication.

### Get Track Catalogue

Returns the static cup/course list used to build the track picker.

**Endpoint:** `GET /api/rankings/tracks`

**Response:**
```json
{
  "cups": [
    {
      "name": "Mushroom Cup",
      "internalName": "Mushroom",
      "dlc": false,
      "tracks": [
        { "id": 27, "name": "Mario Kart Stadium", "internalName": "Gu_FirstCircuit" }
      ]
    }
  ]
}
```

### Get Track Ranking

Time trial times for a single course.

**Endpoint:** `GET /api/rankings/track/:trackId`

**Query Parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `order` | string | `best` | `best` for the fastest times, `worst` for the slowest ones |
| `view` | string | `current` | `current` = live top 10 from Pretendo, `all` = the full local archive |
| `realistic` | `0`/`1` | `0` | `1` drops every time flagged as implausible |

Entries carry two positions: `rank` is the position in the upstream list, while
`displayRank` is the position after filtering. With `realistic=1` the remaining
entries are renumbered from 1, so a list whose two fastest times were hidden
still reads 1, 2, 3 - `rank` then still shows 3, 4, 5.

With `view=all` the response is capped at 500 entries; `total`, `limit` and
`truncated` describe what was left out. If the live fetch fails, `view=current`
falls back to the last archived list and reports `source: "archive-fallback"`.

**Example:**
```bash
curl -b cookies.txt "http://localhost:3000/api/rankings/track/27?order=best"
```

**Response:**
```json
{
  "track": {
    "id": 27,
    "name": "Mario Kart Stadium",
    "internalName": "Gu_FirstCircuit",
    "cupName": "Mushroom Cup",
    "cupInternalName": "Mushroom",
    "dlc": false
  },
  "order": "best",
  "entries": [
    {
      "rank": 1,
      "displayRank": 1,
      "pid": 1799999,
      "miiName": "Mick",
      "score": 92345,
      "time": "1:32.345",
      "recordedAt": "2026-01-05T12:34:56.000Z",
      "firstSeenAt": "2026-07-02T09:14:11.000Z",
      "lastSeenAt": "2026-08-09T08:10:02.000Z",
      "unrealistic": false,
      "unrealisticReason": null,
      "id": 412,
      "blocked": false,
      "reportStatus": null,
      "player": {
        "id": 42,
        "miiName": "Mick",
        "totalRaces": 128,
        "lastSeen": "2026-08-08T20:11:03.000Z",
        "isFavorite": true
      }
    }
  ],
  "cutoff": { "cutoffMs": 85270, "source": "gap", "gapMs": 83770 },
  "unrealisticCount": 2,
  "blockedCount": 0,
  "realisticOnly": false,
  "view": "current",
  "source": "live",
  "total": null,
  "limit": null,
  "truncated": false,
  "cachedAt": "2026-08-09T08:18:18.878Z"
}
```

`player` is `null` when the PID has never been seen by this logger.
`recordedAt` is when the time was driven (reported by the API), `firstSeenAt` is
when this logger first observed it.

`cutoff.source` is `manual` when an admin set a threshold for the track, `gap`
when it was detected automatically and `none` when nothing looks implausible.

`id` is the archive row - it is what `POST /api/rankings/report` refers to.
`blocked` marks a time an admin took out after a user report; such an entry is
`unrealistic` with `unrealisticReason: "blocked"` even when `cutoff.source` is
`none`. `reportStatus` is the requesting user's own report on that time, if any.

**Error Responses:**

| Status | Meaning |
|--------|---------|
| `400` | Unknown or non-numeric track ID |
| `401` | Not authenticated |
| `502` | The Pretendo ranking backend is unreachable or returned an error |

### Get All Track Records

Current record holder (rank 1) for every course. This fans out into one upstream
request per track (bounded to 4 in parallel), so it is cached for 10 minutes by
default. Individual track failures are reported per row instead of failing the
whole response.

**Endpoint:** `GET /api/rankings/overview`

**Response:**
```json
{
  "tracks": [
    {
      "track": { "id": 27, "name": "Mario Kart Stadium", "cupName": "Mushroom Cup", "dlc": false },
      "record": {
        "rank": 1,
        "pid": 1799999,
        "miiName": "Mick",
        "score": 92345,
        "time": "1:32.345",
        "recordedAt": "2026-01-05T12:34:56.000Z",
        "player": null
      },
      "entryCount": 10,
      "error": null
    },
    {
      "track": { "id": 31, "name": "Rainbow Road", "cupName": "Special Cup", "dlc": false },
      "record": null,
      "entryCount": 0,
      "error": "Rankings API error: 500 Internal Server Error"
    }
  ],
  "cachedAt": "2026-08-09T08:18:19.104Z"
}
```

### Get Recently Discovered Times

Times the archiver observed for the first time, newest first.

**Endpoint:** `GET /api/rankings/recent?limit=50&pollId=12`

Each entry carries its `track` plus the same fields as a ranking entry, including
`firstPollId` (the poll that discovered it). Pass `pollId` to list only what one
cycle found.

### Get a Player's Best Times

Best archived time per course for one logged player - the data behind the
"Time Trial Best Times" section of the player details.

**Endpoint:** `GET /api/rankings/player/:playerId`

`playerId` is the local player ID (not the Pretendo PID).

```json
{
  "player": { "id": 42, "pid": 1799999, "miiName": "Mick" },
  "times": [
    {
      "track": { "id": 27, "name": "Mario Kart Stadium", "cupName": "Mushroom Cup" },
      "time": "1:32.345",
      "score": 92345,
      "rank": 1,
      "inCurrentList": true,
      "unrealistic": false,
      "cutoff": { "cutoffMs": null, "source": "none", "gapMs": null },
      "trackTimeCount": 3,
      "recordedAt": "2026-01-05T12:34:56.000Z",
      "firstSeenAt": "2026-07-02T09:14:11.000Z"
    }
  ],
  "totalTimes": 58,
  "trackCount": 21,
  "inCurrentLists": 4
}
```

One row per course, the player's fastest there. `trackTimeCount` is how many of
their times that course holds in total. Rows still on a leaderboard come first
ordered by rank, the rest follow by course name. Returns `404` for an unknown
player.

### Deep links into a list

The rankings page reads query parameters, so other views can link straight to a
specific list: `/rankings?track=27&view=all&order=worst&realistic=1`. `track` is
required and selects both the cup and the course; the rest are optional and
default to the current best-times list. An unknown or non-numeric `track` is
ignored and the page opens normally.

### List Poll Cycles

**Endpoint:** `GET /api/rankings/polls?limit=20`

```json
{
  "polls": [
    {
      "id": 12,
      "startedAt": "2026-08-09T09:10:00.000Z",
      "finishedAt": "2026-08-09T09:12:14.000Z",
      "status": "completed",
      "triggeredBy": "scheduled",
      "tracksTotal": 48,
      "tracksOk": 47,
      "tracksFailed": 1,
      "timesCreated": 24,
      "timesUpdated": 896,
      "durationMs": 134000,
      "lastError": "Rainbow Road: Rankings API error: 500 Internal Server Error"
    }
  ]
}
```

---

## The archive and implausible times

### Why there is an archive

**The public HTTP API is capped at 10 entries per direction and the cap is not
configurable.** `app/api/rankings/[id]/route.ts` in `PretendoNetwork/mario_kart_8_website`
calls the backend with a hardcoded `limit: 10` and reads only one query
parameter, `desc`. The underlying gRPC call (`GetTimeTrialRanking`) does take a
`limit`, but that service is internal (`grpc_host` defaults to `localhost:50051`)
and requires an `X-API-Key` that only the website server holds. The route has not
changed since it was added in April 2024.

The cap applies to HTTP only - see [Full leaderboards over NEX](#full-leaderboards-over-nex)
for the protocol the game itself uses, which has no such limit.

With the HTTP source the archive widens that window in two ways:

- both directions are polled, so one cycle sees up to 20 distinct times, not 10
- every observation is stored permanently, so a time that later drops out of the
  top 10 stays visible and the archive grows well past what the API shows

The archiver runs every 10 minutes by default, polls all 48 courses, and records
`firstSeenAt` for each new time. Its schedule, its source and the NEX credentials
are configured in the admin panel under *Toplisten*; see
[Admin: Rankings Configuration](#admin-rankings-configuration).

On the HTTP path requests go out in parallel with a small concurrency, but the
writes are applied sequentially - concurrent transactions from one batch
otherwise collide on SQLite.

### Lists are derived from poll data

Every cycle is stored as a poll record, and each archived time carries the poll
that first saw it (`firstPollId`) and the poll that last saw it (`lastPollId`).
Each course additionally remembers which poll its current list came from, so the
live top 10 is exactly the set of times carrying that course's poll stamp, in
`lastRank` order - no per-row "is current" flag is rewritten on each cycle. When
a poll fails for a course, its pointer stays on the last good cycle, which is
what the `archive-fallback` source serves.

Track responses report the poll their current list came from as `pollId`.

### How implausible times are detected

Within a genuine top 10 consecutive times are usually less than a second apart, so
a hacked time is separated from the honest field by a large gap. Counting from the
top, every gap larger than 1000 ms is collected; the **last** one becomes the
cutoff, and every time faster than it is flagged `unrealistic`.

The cutoff is derived from the course's **ten fastest archived times**, never
from the whole archive, where big gaps are normal. Ten keeps the sample as tight
as a live top 10, which is what the gap rule needs; taking it from the archive
rather than the current list means a hacked time stays flagged after it leaves
the live leaderboard - removed upstream, or pushed out of the top 10 - which is
exactly when it would otherwise become invisible to the rule. It also makes the
result independent of which source the last poll used.

A manual per-course threshold always wins over the automatic value. This is also
the escape hatch for the rule's weak spot: if the large gap sits at the *bottom*
of the list (one very slow time in tenth place), everything above it gets flagged.

A third signal sits on top of both: a time an admin blocked after a user report
is always `unrealistic`, whatever the cutoff says. Entries carry
`unrealisticReason`, either `"blocked"` (a human decision about this one time) or
`"cutoff"` (the automatic rule), and track responses count them separately as
`unrealisticCount` and `blockedCount`.

### Reporting a time

**Endpoint:** `POST /api/rankings/report`

**Authentication:** Required (any logged-in user)

**Body:** `multipart/form-data` with `trackTimeId`, an optional `reason` and an
optional `evidence` image (PNG/JPEG/WebP/GIF, 8 MB by default). Plain JSON still
works when there is no file.

```json
{ "success": true, "reportId": 7, "status": "pending", "hasEvidence": true }
```

A report never blocks a time on its own - it only asks an admin to look. Each
user can report a given time once; a second attempt returns `409` together with
the status of the existing report. Unknown IDs return `404`, an already blocked
time `409`.

Entries in track responses carry `reportStatus` for the requesting user
(`pending`, `approved`, `rejected` or `null`), so the UI can show the report
button only where it still makes sense.

Filing a report notifies every active admin by e-mail (notification address if
set, otherwise the account address; silently skipped when SMTP is unconfigured)
and makes the dashboard widget appear. Mail failures are logged but never lose
the report.

**Endpoint:** `GET /api/rankings/report/:id/evidence`

Serves the uploaded image. Visible to the reporter and to admins only.

---

## User-entered times and evidence

Everything in this section follows the same rule: a user submits, an admin
decides, and nothing is publicly visible in between.

### Uploads

Images are stored outside `public/` (`MK8_UPLOAD_DIR`, default
`./data/uploads/rankings`) under a random file name - the original name is user
input and never touches the disk. They are served through
`GET /api/rankings/media/:id/file`, which enforces the same visibility rule as
everything else: approved files are visible to any logged-in user, pending ones
only to their submitter and to admins.

Every upload is re-encoded before it is stored: scaled into
`MK8_UPLOAD_MAX_WIDTH` × `MK8_UPLOAD_MAX_HEIGHT` (800 × 600 by default, never
enlarged) and written as JPEG at `MK8_UPLOAD_QUALITY` (72). A 6 MB PNG capture
typically ends up under 100 KB, and the stored `mimeType` is always
`image/jpeg` regardless of what was sent. Decoding the file doubles as
validation - anything that is not a readable image is rejected with `400` and
deleted, whatever its declared content type said. `MK8_UPLOAD_MAX_BYTES` still
caps what is accepted for processing in the first place.

Processing uses `sharp`, a native module requiring Node.js 18.17 or newer (the
Docker image ships Node 22, and `engines.node` asks for 20.9+). When it
cannot be loaded the application still starts, logs a warning and stores uploads
unchanged - the size limit and the MIME filter keep working, only the scaling
and the decode check are skipped.

### Enter a time

**Endpoint:** `POST /api/rankings/times`

**Body:** `multipart/form-data`

| Field | Required | Description |
|-------|----------|-------------|
| `trackId` | yes | Course ID |
| `time` | yes | `m:ss.mmm`, `ss.mmm` or plain milliseconds |
| `screenshot` | yes | Image of the result screen |
| `pid` | admins only | Pretendo player ID; ignored for normal users |
| `miiName` | no | Defaults to the linked profile name |
| `note` | no | Free text for the admin |

```json
{ "success": true, "trackTimeId": 921, "status": "pending" }
```

**A normal user can only enter times for their own player ID.** The PID is taken
from their linked Pretendo profile; a request naming a different one is rejected
with `403` (`You can only enter times for your own player ID`), and an account
without a linked profile gets `403` as well. Admins may pass any `pid` and enter
times on behalf of other players; the form offers them the same
`GET /api/players/search` lookup the lobby list uses for its player
comparison. The rule is enforced server-side - the locked field in the form is
only the visible half of it.

The row lands in the normal archive table with `approvalStatus: "pending"` and
is filtered out of every read path - lists, the overview, player best times and
the cutoff analysis alike - until an admin approves it. After approval it sorts
into the archive view at the position its time deserves and is marked
`userSubmitted: true`.

A time that a later poll finds in the official ranking is approved
automatically: appearing in Pretendo's own list is stronger evidence than any
screenshot.

`409` means the same course/player/time already exists; the response carries the
existing `trackTimeId`.

### Propose a screenshot or a video

**Endpoint:** `POST /api/rankings/time/:id/media`

**Body:** `multipart/form-data` with `type` (`image` or `youtube`), plus either
`screenshot` or `url`, and an optional `caption`. YouTube URLs are accepted in
the `watch?v=`, `youtu.be`, `embed` and `shorts` forms, as well as a bare
11-character video ID.

```json
{ "success": true, "mediaId": 12, "status": "pending" }
```

### Time detail

**Endpoint:** `GET /api/rankings/time/:id`

Everything the detail modal shows for one archived time.

```json
{
  "entry": {
    "id": 921,
    "pid": 1966001,
    "miiName": "Formularfahrer",
    "time": "1:26.100",
    "unrealistic": false,
    "userSubmitted": true,
    "approvalStatus": "approved",
    "submissionNote": "Mit Screenshot belegt",
    "reportStatus": null,
    "media": [
      {
        "id": 11,
        "type": "image",
        "status": "approved",
        "fileUrl": "/api/rankings/media/11/file",
        "caption": null,
        "submitter": { "id": 3, "username": "fahrer" }
      },
      {
        "id": 12,
        "type": "youtube",
        "status": "approved",
        "youtubeId": "dQw4w9WgXcQ",
        "watchUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
        "thumbnailUrl": "https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg"
      }
    ]
  },
  "track": { "id": 32, "name": "3DS DK Jungle", "cupName": "Banana Cup" },
  "cutoff": { "cutoffMs": null, "source": "none", "gapMs": null },
  "submitter": { "id": 3, "username": "fahrer" },
  "reportCount": 0,
  "canReport": true
}
```

`reports` (the full list with reasons) is only included for admins. A pending or
rejected submission returns `404` for everyone except its author and admins.

Track and `recent` responses carry the same `media` array per entry, which is
what the preview column in the lists renders.

### Admin: Submitted times

**Endpoint:** `GET /admin/api/rankings/submissions?status=pending|approved|rejected|all`
**Endpoint:** `POST /admin/api/rankings/submissions/:id/approve|reject` - body `{ "note": "optional" }`
**Endpoint:** `PUT /admin/api/rankings/submissions/:id` - body `{ "time": "1:24.500", "miiName": "…", "note": "…" }`
**Endpoint:** `DELETE /admin/api/rankings/submissions/:id`

Approving releases the time and its screenshots in one go. Rejecting keeps the
row for the record but leaves it invisible. `PUT` exists so a mistyped time can
be corrected without asking the user to submit again; deleting also removes the
uploaded files. Only rows with `source = 'user'` can be reached through these
endpoints - polled times are data, not moderation objects.

### Admin: Proposed media

**Endpoint:** `GET /admin/api/rankings/media?status=pending|approved|rejected|all`
**Endpoint:** `POST /admin/api/rankings/media/:id/approve|reject`
**Endpoint:** `DELETE /admin/api/rankings/media/:id`

### Admin: Pending counts

**Endpoint:** `GET /admin/api/rankings/moderation/pending`

```json
{ "reports": 1, "submissions": 2, "media": 3, "total": 6 }
```

Drives the dashboard widget, which stays hidden while `total` is zero.

---

### Admin: List Reports

**Endpoint:** `GET /admin/api/rankings/reports?status=pending|approved|rejected|all`

```json
{
  "reports": [
    {
      "id": 7,
      "status": "pending",
      "reason": "way below the world record",
      "reviewNote": null,
      "createdAt": "2026-08-10T09:12:00.000Z",
      "reviewedAt": null,
      "reporter": { "id": 3, "username": "melder" },
      "reviewer": null,
      "time": {
        "id": 412,
        "trackId": 27,
        "trackName": "Mario Kart Stadium",
        "cupName": "Mushroom Cup",
        "pid": 1800000,
        "miiName": "Racer0",
        "score": 812,
        "time": "0:00.812",
        "blocked": false
      }
    }
  ],
  "pending": 1,
  "status": "pending"
}
```

`status` defaults to `pending`. `pending` is always the total number of open
reports, regardless of the filter - the dashboard widget uses it.

### Admin: Approve or Reject a Report

**Endpoint:** `POST /admin/api/rankings/reports/:id/approve`
**Endpoint:** `POST /admin/api/rankings/reports/:id/reject`
**Endpoint:** `DELETE /admin/api/rankings/reports/:id`

**Body:** `{ "note": "optional" }`

Approving sets `blocked` on the archived time. Rejecting lifts the block again,
unless another approved report still covers the same time. Both decisions are
written to the activity log. `DELETE` is housekeeping - it removes the report
and its uploaded evidence rather than deciding anything.

### Full leaderboards over NEX

The game does not use the website API - it speaks the NEX Ranking protocol
(0x70) over PRUDP. `GetRanking` there takes an offset and a count, so a
leaderboard can be read in full. Pretendo implements it via
`nex-protocols-common-go/ranking/get_ranking.go`; `RankingOrderParam.Length` is a
u8, so a single response holds at most 255 entries and longer lists are paged.

The logger can use that as an optional source. `scripts/nex_rankings.py` performs
the same login the console does - NNAS account login, NEX token, PRUDP session -
and returns whole leaderboards as JSON; `src/services/nexRankings.js` runs it and
feeds the result into the same archive the HTTP path writes to. There is no NEX
implementation for Node, hence the Python sidecar.

**It needs real console data**, because NEX authenticates consoles rather than
API clients. Pretendo's account server runs `consoleStatusVerificationMiddleware`
on the login route: it verifies the device certificate's ECDSA signature against
Nintendo's Wii U device CA public key and checks that the `X-Nintendo-Device-ID`
header matches the device ID encoded in the certificate name. A fabricated
certificate is rejected as `Unlinked device`. Only the serial number goes
unverified - their own comment explains that serials cannot be checked safely.

So a Pretendo account, device ID, serial number, system version and device
certificate are all required. That is the same data Cemu needs for online play.
Without them the archiver uses the HTTP API instead. Continuous NEX polling
should be cleared with the Pretendo team first - it is far more load than an
HTTP request.

Setup - install the sidecar, then configure it in the admin panel under
*Toplisten* → *Datenquelle & Zugangsdaten*:

```bash
python3 -m venv .venv-nex
.venv-nex/bin/pip install -r scripts/requirements-nex.txt
# interpreter path goes into the form: .venv-nex/bin/python
```

The `MK8_NEX_*` environment variables still work. They seed the settings on first
run and remain the fallback for anything left empty in the form, so an existing
`.env` deployment keeps working unchanged.

One cycle reads all 48 courses through a **single** NEX session - the console
login is the expensive part and is not repeated per course. The sidecar streams
one JSON line per course as soon as it is read (`--stream`), so each course is
archived immediately, the admin panel can show progress, and a run that dies
halfway keeps everything it already delivered.

The script can also be run on its own:

```bash
.venv-nex/bin/python scripts/nex_rankings.py --check          # verify credentials
.venv-nex/bin/python scripts/nex_rankings.py --tracks 27 --limit 255
.venv-nex/bin/python scripts/nex_rankings.py --tracks all --stream
```

When a poll used NEX, its `ranking_polls.source` is `nex`, and `view=current`
responses report `source: "nex"` and serve the full ranked list from the archive
instead of the 10 entries HTTP would return. If the sidecar fails for any reason
- expired credentials, missing dependencies, NEX unreachable - the poll falls
back to HTTP and records why in the poller status. That fallback can be turned
off, in which case a failed cycle is simply recorded as failed.

**The plausibility cutoff still looks at ten times only.** Over hundreds of
entries a gap larger than a second is completely normal, so applying the "last
gap" rule to a full leaderboard would push the cutoff far down and flag nearly
everything.

### Admin: Rankings Configuration

**Endpoint:** `GET /admin/api/rankings/poller/config`

Everything the archiver runs on: schedule, source selection and NEX credentials.

```json
{
  "poller": {
    "pollEnabled": true,
    "pollIntervalMinutes": 10,
    "httpFallback": true,
    "includeWorst": true
  },
  "nex": {
    "enabled": true,
    "username": "pnid",
    "password": "",
    "passwordSet": true,
    "passwordType": "",
    "deviceId": "1a2b3c4d",
    "serialNumber": "FW1234567890",
    "systemVersion": "0x1010",
    "deviceCert": "",
    "deviceCertSet": true,
    "regionId": "4",
    "regionName": "EUR",
    "countryName": "DE",
    "language": "de",
    "accountUrl": "account.pretendo.cc",
    "python": "python3",
    "limit": "255",
    "delay": "0.5",
    "missing": [],
    "configured": true
  },
  "sources": { "username": "settings", "language": "default", "...": "env | settings | default | unset" },
  "scriptPresent": true,
  "status": { "...": "same shape as the poller status" }
}
```

The password and the device certificate are **never** returned - only
`passwordSet` / `deviceCertSet` say whether one is stored. `sources` reports where
each value currently comes from, so a field filled in from an `.env` is
distinguishable from one saved through the form.

**Endpoint:** `PUT /admin/api/rankings/poller/config`

Takes the same `{ poller, nex }` shape. Only the keys present in the body are
written. For the two secrets:

| value sent | effect |
|---|---|
| key absent, or `""` | keeps the stored value |
| a string | stores it |
| `null` | clears it |

`deviceId` and `systemVersion` must be hexadecimal, `nex.limit` is 1-1000,
`nex.delay` is 0-60 seconds and `poller.pollIntervalMinutes` is 1-1440; anything
else is rejected with `400`. On success the archiver is restarted with the new
values and the response carries the re-read configuration plus the poller status.

The two secrets are also excluded from `GET /admin/api/settings` and rejected by
`PUT /admin/api/settings/:key`, so this endpoint is the only way to set them.

### Admin: Check NEX Credentials

**Endpoint:** `POST /admin/api/rankings/nex/check`

Runs the sidecar's `--check` mode: logs in, reports the PID, fetches nothing.
Returns `{ success, configured, pid, error, status }`, where `status.missingConfig`
lists the setting keys that are still unset.

### Admin: Rankings Poller

**Endpoint:** `POST /admin/api/rankings/poller/:action`

`action` is one of `start`, `stop`, `restart`, `poll` (run a full cycle now) or
`status`. Returns `{ success: true, status: {...} }`.

While a cycle is running, `status.progress` reports how far along it is - a full
NEX sweep takes minutes, so the admin panel polls this to drive a progress bar:

```json
{
  "progress": {
    "source": "nex",
    "done": 31,
    "total": 48,
    "currentTrackId": 45,
    "entriesSeen": 4210
  }
}
```

`progress` is `null` when no cycle is in flight.

### Admin: List Thresholds

**Endpoint:** `GET /admin/api/rankings/thresholds`

```json
{
  "tracks": [
    {
      "trackId": 27,
      "trackName": "Mario Kart Stadium",
      "cupName": "Mushroom Cup",
      "archivedTimes": 56,
      "currentListSize": 10,
      "thresholdMs": null,
      "thresholdTime": null,
      "autoCutoffMs": 85270,
      "autoCutoffTime": "1:25.270",
      "autoGapMs": 83770,
      "effectiveCutoffMs": 85270,
      "effectiveCutoffTime": "1:25.270",
      "effectiveSource": "gap",
      "unrealisticCount": 2
    }
  ]
}
```

### Admin: Set or Clear a Threshold

**Endpoint:** `PUT /admin/api/rankings/thresholds/:trackId`

**Body:** `{ "threshold": "1:23.456", "note": "optional" }`

Accepts `m:ss.mmm`, `ss.mmm` or plain milliseconds. An empty string or `null`
deletes the override and restores automatic detection. Invalid input returns
`400`.

---

## Favorites

**Note:** All favorite endpoints require authentication.

### Get Favorites

Get all favorite players for the authenticated user.

**Endpoint:** `GET /api/favorites`

**Authentication:** Required

**Response:**
```json
{
  "favorites": [
    {
      "id": 1,
      "userId": 1,
      "playerId": 5,
      "pid": "abc123",
      "notes": "Good player",
      "notifyOnActive": true,
      "createdAt": "2025-01-01T10:00:00.000Z",
      "Player": {
        "id": 5,
        "pid": "abc123",
        "miiName": "Player1",
        "totalRaces": 150,
        "lastSeen": "2025-01-10T11:00:00.000Z"
      }
    }
  ]
}
```

### Add Favorite

Add a player to favorites.

**Endpoint:** `POST /api/favorites`

**Authentication:** Required

**Request Body:**
```json
{
  "playerId": 5,
  "notes": "Good player",
  "notifyOnActive": true
}
```

**Response:**
```json
{
  "id": 1,
  "userId": 1,
  "playerId": 5,
  "pid": "abc123",
  "notes": "Good player",
  "notifyOnActive": true,
  "createdAt": "2025-01-10T12:00:00.000Z"
}
```

### Update Favorite

Update a favorite player's settings.

**Endpoint:** `PUT /api/favorites/:id`

**Authentication:** Required

**Parameters:**
- `id`: Favorite ID

**Request Body:**
```json
{
  "notes": "Updated notes",
  "notifyOnActive": false
}
```

**Response:**
```json
{
  "id": 1,
  "userId": 1,
  "playerId": 5,
  "notes": "Updated notes",
  "notifyOnActive": false
}
```

### Delete Favorite

Remove a player from favorites.

**Endpoint:** `DELETE /api/favorites/:id`

**Authentication:** Required

**Parameters:**
- `id`: Favorite ID

**Response:**
```json
{
  "success": true
}
```

### Get Active Favorites

Get favorite players who are currently in active races.

**Endpoint:** `GET /api/favorites/active`

**Authentication:** Required

**Response:**
```json
{
  "activeFavorites": [
    {
      "favorite": {
        "id": 1,
        "playerId": 5,
        "pid": "abc123",
        "notes": "Good player"
      },
      "player": {
        "id": 5,
        "pid": "abc123",
        "miiName": "Player1",
        "totalRaces": 150
      },
      "race": {
        "id": 1,
        "gid": 123456,
        "gameModeName": "VS Race",
        "region": "Europe",
        "isActive": true
      },
      "session": {
        "joinedAt": "2025-01-10T10:30:00.000Z"
      }
    }
  ]
}
```

---

## Poller

### Get Poller Status

Get the current status of the API poller.

**Endpoint:** `GET /api/poller/status`

**Authentication:** Not required

**Response:**
```json
{
  "isRunning": true,
  "pollIntervalMs": 30000,
  "apiUrl": "https://mario-kart-8.pretendo.network/api/gatherings",
  "lastPollTime": "2025-01-10T12:00:00.000Z",
  "pollCount": 1500,
  "errorCount": 0,
  "lastRaceTime": "2025-01-10T11:30:00.000Z"
}
```

### Get Poller Errors

Get recent poller errors and error statistics.

**Endpoint:** `GET /api/poller/errors`

**Authentication:** Not required

**Query Parameters:**
- `limit` (optional): Number of errors to return (default: 50, max: 500)

**Example Request:**
```
GET /api/poller/errors?limit=100
```

**Response:**
```json
{
  "summary": {
    "total": 150,
    "last24h": 5,
    "lastError": {
      "message": "Connection timeout",
      "timestamp": "2025-01-10T11:45:00.000Z"
    }
  },
  "errors": [
    {
      "id": 150,
      "message": "Connection timeout",
      "stack": "Error: Connection timeout\n    at ...",
      "pollCount": 1500,
      "errorCount": 1,
      "timestamp": "2025-01-10T11:45:00.000Z"
    }
  ]
}
```

---

## User Settings

### Update Notification Settings

Update notification settings for the authenticated user.

**Endpoint:** `PUT /api/user/notification-settings`

**Authentication:** Required

**Request Body:**
```json
{
  "notificationsEnabled": true,
  "notificationEmail": "user@example.com"
}
```

**Response:**
```json
{
  "success": true,
  "user": {
    "id": 1,
    "username": "user",
    "notificationsEnabled": true,
    "notificationEmail": "user@example.com"
  }
}
```

### Pretendo Profiles

All history in this logger is keyed by the Pretendo player ID, not by the account:
lobbies via `players.pid`, VR points via the player record, time trial times via
`track_times.pid`. An account that simply re-points at a new Pretendo ID would stop
seeing everything the old ID recorded.

An account can therefore own several profiles:

- The **primary** profile (`users.pretendoPid`) is where new data is written: VR
  uploads, hand-entered times and the Aroma plugin token file.
- **Every** linked profile counts as the user's own when reading: profile page, VR
  history, the `own` VR permission, time submissions, and the registered-player markers.

Linking a second profile requires the per-user `multiPretendoLink` permission, which an
admin grants in the user dialog. Without it, linking a new profile replaces the old one
(the behaviour before this existed). Unlinking never deletes recorded data - it only
stops being shown as this account's own.

#### List Linked Profiles

**Endpoint:** `GET /api/user/pretendo-links`

**Authentication:** Required

**Response:**
```json
{
  "links": [
    { "pid": "1234567890", "miiName": "NewMii", "isPrimary": true, "linkedAt": "2026-08-01T10:00:00.000Z" },
    { "pid": "9876543210", "miiName": "OldMii", "isPrimary": false, "linkedAt": "2025-03-14T09:00:00.000Z" }
  ],
  "multiPretendoLink": true
}
```

#### Link a Profile

**Endpoint:** `POST /api/user/link-pretendo`

**Authentication:** Required

**Request Body:**
```json
{
  "pretendoPid": "9876543210",
  "pretendoName": "OldMii"
}
```

**Response:**
```json
{
  "success": true,
  "message": "Pretendo profile linked - your existing profiles and their history are kept",
  "action": "linked_additional",
  "replaced": false,
  "links": [ /* as above */ ]
}
```

| `action` | Meaning |
|----------|---------|
| `linked_primary` | First profile of the account, became the primary one |
| `linked_additional` | Added alongside the existing profiles (needs `multiPretendoLink`) |
| `primary_switched` | Already linked, is now the primary profile |
| `replaced` | Replaced the previous profile (account without `multiPretendoLink`) |
| `unchanged` | Already the primary profile |

**Errors:**
- `409 PID_TAKEN` — the profile is already linked to another account
- `400 INVALID_PID` — the player ID is not numeric

#### Switch the Primary Profile

**Endpoint:** `PUT /api/user/pretendo-links/:pid/primary`

**Authentication:** Required — the player ID must already be linked to the account

**Response:** `{ "success": true, "links": [ /* … */ ] }`

#### Console Assignments

The Aroma plugin uploads with an account token and the console serial, but no player ID.
For an account with several profiles, the console decides which profile an upload is
recorded for. The mapping is learned from the VR history: whichever linked profile has
been receiving points from that serial keeps receiving them.

A console with no earlier uploads has nothing to learn from and falls back to the primary
profile; that assumption is stored and reported as `evidenceCount: 0`, and the user can
correct it. A manual assignment is never overwritten by the learning path.

Note that a serial identifies a console, not an account — two profiles played on the same
console cannot be told apart this way.

**Endpoint:** `GET /api/user/console-links`

**Authentication:** Required

**Response:**
```json
{
  "consoles": [
    {
      "consoleSerial": "WUP10123456789",
      "pid": "9876543210",
      "miiName": "OldMii",
      "source": "learned",
      "evidenceCount": 12,
      "uploadCount": 13,
      "lastUsedAt": "2026-08-10T19:00:00.000Z",
      "stale": false
    }
  ],
  "links": [ /* the account's Pretendo profiles */ ]
}
```

| Field | Description |
|-------|-------------|
| `source` | `learned` (derived from the VR history) or `manual` (set by the user) |
| `evidenceCount` | VR points backing a learned mapping; `0` means the primary profile was assumed |
| `uploadCount` | VR points recorded from this console for the assigned profile |
| `stale` | The assigned profile is no longer linked - the next upload re-learns the mapping |

**Assign a console:** `PUT /api/user/console-links/:consoleSerial` with `{ "pid": "9876543210" }`
— stores a manual mapping. The profile must be linked to the account (`404 NOT_LINKED` otherwise).

**Forget a console:** `DELETE /api/user/console-links/:consoleSerial` — the next upload
works the assignment out again.

Both return `{ "success": true, "consoles": [ /* … */ ] }`. Changing an assignment affects
future uploads only; VR points already recorded stay where they are.

#### Unlink a Single Profile

**Endpoint:** `DELETE /api/user/pretendo-links/:pid`

**Authentication:** Required

Unlinking the primary profile promotes the oldest remaining one, so an account never
keeps profiles it cannot write to. Removing the last profile clears the account's link
entirely. To unlink everything at once use `POST /auth/profile/unlink-pretendo`.

**Response:** `{ "success": true, "links": [ /* remaining profiles */ ] }`

### Get API Token

Whether the signed-in account has an API token (for the MK8Logger plugin and the
`/api/v1` endpoints), shortened unless asked for in full.

**Endpoint:** `GET /api/user/api-token`

**Authentication:** Required (session)

**Query Parameters:**
- `full` (optional): `1` returns the whole token instead of the shortened one

**Response:**
```json
{ "hasToken": true, "token": "a1b2c3d4...9f8e7d6c", "full": false }
```

`token` is `null` without a token.

### Generate API Token

Creates a new token. **The previous token stops working immediately** - the plugin
needs the new token file.

**Endpoint:** `POST /api/user/api-token/generate`

**Authentication:** Required (session)

**Response:**
```json
{ "success": true, "token": "a1b2c3d4e5f6…" }
```

### Download Token File

The token as `<pid>_api_token.txt`, the file the MK8Logger plugin reads from the SD card.

**Endpoint:** `GET /api/user/api-token/download`

**Authentication:** Required (session)

**Response:** `text/plain` attachment. `404` without a token, `400` without a linked
Pretendo profile (the file name contains the PID).

### Revoke API Token

Deletes the token; plugin uploads with it are rejected from then on.

**Endpoint:** `DELETE /api/user/api-token`

**Authentication:** Required (session)

**Response:**
```json
{ "success": true, "message": "API token revoked" }
```

### Update Preferences

Language and time zone of the account.

**Endpoint:** `PUT /api/user/preferences`

**Authentication:** Required (session)

**Request Body** (both optional):
```json
{ "language": "de", "timezone": "Europe/Berlin" }
```

- `language`: `de` or `en`
- `timezone`: an IANA time zone name; times on the pages are shown in it

**Response:**
```json
{ "success": true, "message": "Preferences updated successfully" }
```

### Check for Races Since Sign-In

Whether one of the account's players was in a lobby since the session started (or in
the last 24 hours). The site uses it to suggest entering VR.

**Endpoint:** `GET /api/user/check-recent-races`

**Authentication:** Required (session)

**Response:**
```json
{ "hasRacedSinceLogin": true, "playerId": 5, "lastRaceTime": "2026-09-27T19:02:11.000Z" }
```

Without a linked Pretendo profile: `{ "hasRacedSinceLogin": false }`.

### Download Files

The MK8Logger plugin and its manuals. Each download is recorded in the activity log.

**Endpoint:** `GET /api/downloads/:filename`

**Authentication:** Required (session)

**Files:** `mk8logger.wps` (download), `BENUTZERHANDBUCH.md`, `USER_GUIDE.md` (shown in
the browser). Any other name: `404`.

The manuals and this API documentation can also be read as pages: `/docs/benutzerhandbuch`,
`/docs/user-guide` and `/docs/api` (`?format=md` for the Markdown file).

### Set Language Preference

The DE/EN switch of the website. Sets the `lang` cookie; for a signed-in account the
language is also stored as the account's language, which every page uses from then on.
An explicit `?lang=de|en` in a page address still wins for that one page.

**Endpoint:** `POST /api/language`

**Authentication:** Not required

**Request Body:**
```json
{
  "lang": "de"
}
```

**Response:**
```json
{
  "success": true,
  "language": "de",
  "saved": true
}
```

`saved` is `true` when the language was stored in the account (signed in).

---

## VR Points

VR Points (VR rating) is a player skill ranking system tracked separately for VS Race and Battle game modes. Players' VR points can be viewed and updated through the API.

### Get VR Points History

Get the complete VR points history for a specific player.

A VR history belongs to the person, not to a single Pretendo profile. When the player is
linked to an account that owns several profiles, the response covers **all** of them —
otherwise the curve would break off at the point where the account switched profiles.
Every entry names the profile it belongs to, and `players` lists the profiles the history
is made of, the requested one first. A player nobody has linked simply answers with its
own history.

**Endpoint:** `GET /api/vr-points/:playerId`

**Authentication:** Not required

**Parameters:**
- `playerId`: Player ID (numeric database ID)

**Query parameters:**
- `vrType` (optional): `vs_race` or `battle`
- `limit` (optional): maximum number of entries

**Response:** Entries are ordered newest first, across profiles.
```json
{
  "vrPoints": [
    {
      "id": 2,
      "playerId": 6,
      "vrType": "vs_race",
      "points": 1200,
      "recordedAt": "2026-02-03T10:00:00.000Z",
      "consoleSerial": "WUP20987654321",
      "player": { "id": 6, "pid": "2222222222", "miiName": "NewMii" },
      "enteredByUser": { "id": 1, "username": "someone" }
    },
    {
      "id": 1,
      "playerId": 5,
      "vrType": "vs_race",
      "points": 4100,
      "recordedAt": "2026-01-02T10:00:00.000Z",
      "consoleSerial": "WUP10123456789",
      "player": { "id": 5, "pid": "1111111111", "miiName": "OldMii" },
      "enteredByUser": { "id": 1, "username": "someone" }
    }
  ],
  "players": [
    { "id": 5, "pid": "1111111111", "miiName": "OldMii" },
    { "id": 6, "pid": "2222222222", "miiName": "NewMii" }
  ]
}
```

Returns `404` when the player does not exist.

**Note:** VR does not carry over between Pretendo profiles, so the web UI draws one line
per profile by default rather than joining them into a single curve. The lines can also be
split by console, or by both at once.

### Get Latest VR Points

Get the most recent VR points for a specific player (one entry per game mode).

**Endpoint:** `GET /api/vr-points/:playerId/latest`

**Authentication:** Not required

**Parameters:**
- `playerId`: Player ID (numeric database ID)

**Response:**
```json
{
  "vrPoints": [
    {
      "id": 1,
      "playerId": 5,
      "gameMode": 0,
      "gameModeName": "VS Race",
      "vrPoints": 5000,
      "createdAt": "2025-01-10T10:00:00.000Z",
      "updatedAt": "2025-01-10T10:00:00.000Z"
    },
    {
      "id": 2,
      "playerId": 5,
      "gameMode": 1,
      "gameModeName": "Battle",
      "vrPoints": 4500,
      "createdAt": "2025-01-10T10:00:00.000Z",
      "updatedAt": "2025-01-10T10:00:00.000Z"
    }
  ]
}
```

**Key Fields:**
- `gameMode`: 0 for VS Race, 1 for Battle (other game modes don't have VR points)
- `gameModeName`: Human-readable game mode name
- `vrPoints`: Current VR rating for this game mode
- `updatedAt`: Last time this VR rating was updated

### Add or Update VR Points

Add new VR points entries or update existing ones for multiple players.

**Endpoint:** `POST /api/vr-points`

**Authentication:** Required

**Request Body:**
```json
{
  "vrPoints": [
    {
      "playerId": 5,
      "gameMode": 0,
      "vrPoints": 5100
    },
    {
      "playerId": 5,
      "gameMode": 1,
      "vrPoints": 4600
    },
    {
      "playerId": 10,
      "gameMode": 0,
      "vrPoints": 4800
    }
  ]
}
```

**Response:**
```json
{
  "success": true,
  "message": "VR Points gespeichert",
  "created": 3,
  "updated": 0
}
```

**Response Fields:**
- `created`: Number of new VR point entries created
- `updated`: Number of existing VR point entries updated

**Notes:**
- If a VR point entry already exists for the player and game mode, it will be updated
- If no entry exists, a new one will be created
- Multiple players and game modes can be updated in a single request
- Only game modes 0 (VS Race) and 1 (Battle) support VR points

---

### Compare VR of Several Players

The full VR history of two or more players at once, for comparison charts.

**Endpoint:** `GET /api/vr-points/compare`

**Authentication:** Not required

**Query Parameters:**
- `playerIds` (required): comma-separated player IDs, at least two

**Response:**
```json
{
  "players": [
    {
      "id": 5, "pid": 1311404083, "miiName": "Achim",
      "vrPoints": [
        { "id": 16712, "playerId": 5, "vrType": "vs_race", "points": 8802,
          "recordedAt": "2026-09-27T19:02:11.000Z", "enteredBy": 4,
          "consoleSerial": null, "source": "race_result" }
      ]
    }
  ]
}
```

`vrPoints` are newest first and contain both `vs_race` and `battle`. `400` with fewer
than two IDs.

### Get Latest VR of Several Players

Latest and highest VR per player in one request (lobby cards, lists).

**Endpoint:** `GET /api/vr-points/batch-latest`

**Authentication:** Not required

**Query Parameters:**
- `playerIds` (required): comma-separated player IDs

**Response:** an object keyed by player ID; players without any VR are left out.
```json
{
  "5": {
    "vsRace": 8802, "vsRaceAt": "2026-09-27T19:02:11.000Z",
    "battle": 1040, "battleAt": "2026-08-02T18:00:00.000Z",
    "maxVsRace": 8900, "maxBattle": 1100
  }
}
```

## Token-Based API (v1)

These endpoints use API token authentication (Bearer token or `?token=` query parameter) instead of session cookies. See the [VR Points API Guide](VR_POINTS_API_GUIDE.md) for detailed usage.

### User Info

Retrieve settings and profile information for the authenticated token owner.

**Endpoint:** `GET /api/v1/me`

**Authentication:** API Token (Bearer or query parameter)

**Response:**
```json
{
  "id": 1,
  "username": "MeinName",
  "pretendoPid": 1234567890,
  "pretendoName": "MeinMiiName",
  "aromaDebug": false,
  "manualVrPermission": "all",
  "linkedPids": ["1234567890", "9876543210"],
  "multiPretendoLink": true
}
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | User ID |
| `username` | string | Username |
| `pretendoPid` | integer\|null | Primary Pretendo Player ID - uploads are recorded for this profile |
| `pretendoName` | string\|null | Mii name of the primary profile |
| `aromaDebug` | boolean | Whether Aroma Plugin debugging is enabled (admin-controlled) |
| `manualVrPermission` | string | VR entry permission: `"all"`, `"own"`, or `"none"` |
| `linkedPids` | string[] | Every Pretendo Player ID the account owns, primary first |
| `multiPretendoLink` | boolean | Whether the account may link further profiles (admin-controlled) |

**Note:** `pretendoPid` is the profile an upload without a recognised console serial is
recorded for. When the upload carries a `consoleSerial` that is mapped to one of the other
`linkedPids`, that profile wins - see [Console Assignments](#console-assignments).

### VR Points Upload

See [VR Points API Guide](VR_POINTS_API_GUIDE.md) for:
- `POST /api/v1/vr-points` — Single player upload
- `POST /api/v1/vr-points/batch` — Batch upload for multiple players
- `GET /api/v1/vr-points/latest` — Latest VR points
- `GET /api/v1/vr-points/sync/:consoleSerial` — Console sync

### Race Results Upload

Upload the result of one online race. The Aroma plugin calls this at the end of every
race with the placement, race points and VR of all participants, read from game memory.

**Endpoint:** `POST /api/v1/race-results`

**Authentication:** API Token (Bearer or query parameter). The account needs a linked
Pretendo profile.

**Plugin version:** Since v0.12.00 the plugin sends `User-Agent: MK8Logger/<version>`
(e.g. `MK8Logger/v0.12.00`) with every request. The version is stored with the activity log
entry of each upload (race results and VR) and shown in the admin activity log. Uploads that
fail with a server error are logged there too.

**Request Body:**
```json
{
  "consoleSerial": "FW123456789",
  "gameMode": 0,
  "courseId": null,
  "players": [
    { "rank": 1, "points": 15, "miiName": "Kurt", "pid": 1471493879, "vr": 48477,
      "countryId": 49, "regionId": 50 },
    { "rank": 2, "points": 12, "miiName": "PM", "pid": 1234567890, "vr": 9629,
      "countryId": 78, "regionId": 11 },
    { "rank": 6, "points": 7, "miiName": "Ajadue", "vr": 1333, "vrChange": 13,
      "vrExact": 1333.24, "vrBeforeExact": 1320.715,
      "countryId": 78, "regionId": 11, "isLocal": true,
      "miiCreateId": "dbfba88003b3b88d27d9" }
  ]
}
```

| Field | Type | Description |
|-------|------|-------------|
| `consoleSerial` | string | Optional. Decides which of the account's profiles uploads, as for VR uploads |
| `gameMode` | integer | Optional. `0` = VS Race, `1` = Battle |
| `courseId` | integer | Optional. Course ID as in the rankings (16 = Mario Circuit … 63 = Big Blue). An unknown ID is stored as `null`; the race itself is kept |
| `players` | array | 1-12 participants, spectators left out |
| `players[].rank` | integer | Placement 1-12, unique within the race |
| `players[].points` | integer | Race points 0-20 (12 players: 15, 12, 10, 9 … 1) |
| `players[].miiName` | string | Mii name as shown in game, 1-40 characters |
| `players[].pid` | integer | Optional. Pretendo PID as the game keeps it for remote players (not for local players, guests or CPUs) |
| `players[].vr` | number | Optional. VR (BR in battle) after the race, 0-99999, rounded |
| `players[].vrChange` | number | Optional. VR change in this race, if the plugin saw the value before |
| `players[].vrExact` | number | Optional (plugin v0.13.00+). VR after the race with decimals, as the game keeps it between races. Dropped when its whole part is not `vr` |
| `players[].vrBeforeExact` | number | Optional (plugin v0.13.00+). VR at the start of the race with decimals. Dropped when its whole part is not `vr - vrChange` |
| `players[].countryId` | integer | Optional. Country as used by the game (e.g. 78 = Germany, 49 = USA) |
| `players[].regionId` | integer | Optional. Region inside the country |
| `players[].miiCreateId` | string | Optional. Mii create ID, 20 hex characters |
| `players[].driverId` | integer | Optional. Driver as numbered by the game (0 Mario … 36 Villager girl), 0-63 |
| `players[].bodyId` | integer | Optional. Kart/bike/ATV body (0 Standard Kart … 38 Bone Rattler), 0-63 |
| `players[].tireId` | integer | Optional. Tires (0 Standard … 20 Leaf Tires), 0-63 |
| `players[].gliderId` | integer | Optional. Glider (0 Super Glider … 13 Paper Glider), 0-15 |
| `players[].colorVariant` | integer | Optional. Color of Yoshi / Shy Guy, suit of a Mii, 0-255 |
| `players[].miiData` | string | Optional. The Mii as Wii U Mii data (FFLStoreData, 96 bytes = 192 hex characters). Unusable data is dropped, the race is kept |
| `players[].isLocal` | boolean | The uploader's own player. At most one per upload |
| `players[].isGuest` | boolean | A local co-op guest on one of the consoles |
| `players[].guestOfRank` | integer | Optional, guests only. Placement of the guest's host (the main player on the same console) in this race |
| `items` | array | Optional (plugin v0.21.00+). Every item box hit of the race: `{ "p": 2, "item": 8, "rank": 1, "t": 64230 }` - `p` is the index in `players`, `item` the item number 0-20 (0 Banana, 1 Green Shell, 2 Red Shell, 3 Mushroom, 4 Bob-omb, 5 Blooper, 6 Spiny Shell, 7 Triple Mushroom, 8 Super Star, 9 Bullet Bill, 10 Lightning, 11 Golden Mushroom, 12 Fire Flower, 13 Piranha Plant, 14 Boomerang Flower, 15 Coin, 16 Super Horn, 17 Triple Bananas, 18 Triple Green Shells, 19 Triple Red Shells, 20 Crazy Eight), `rank` the place at the hit, `t` the race time in ms. From plugin v0.24.00 also `d` and `d0` (distance to the leader in game units when the plugin noticed the hit and one poll before) and `s` (kart flag Kart+0x66, 0 = player, 1 = CPU); from plugin v0.27.00 `tb`, the item table set the game uses (0-19: VS normal 2, mushrooms 4, shells 6, bananas 8, bob-ombs 10, frantic 18, +1 for CPUs). `GET /api/race-rounds/:id` adds `band` and `chance` (%) for the measured distance and `impossible` per item: the game's own table (`src/services/itemTables.js`, from common/mush/Item.bin) never hands out the item on that place (place 1: distance 0; other places: in no band). Up to 400; invalid entries are skipped. When several consoles report the race, the first upload with items keeps them |

**Item statistics:** `GET /api/race-rounds/:id` lists each player's `items`
(`itemId`, `place`, `raceTimeMs`, in race order) when the race has an item log
(`hasItems`). `GET /api/stats/item-distribution?days=` gives the hits per place
and item over all VS races, `GET /api/players/:id/items` a person's items with
`score.rare` (items under 2 % of the hits on that place among all other players)
against `score.expected`, and `strongFront`: Super Star, Bullet Bill and Golden
Mushroom on places 1-3 against how often all other players get them there. Admins get
the players standing out most under `GET /admin/api/items/suspicious?front=3`
(`players` for rare items in general, `strongFront` with the races of the hits).

**Matching players:** The game keeps the Pretendo PID of every remote player for the race,
and the plugin sends it as `pid`. Those players are matched exactly. For players without
a PID the server looks up the lobby the uploader is in (from the poller's sessions)
and matches the Mii name against the players of that lobby. `matchStatus` in the
response tells how each player was resolved:

| `matchStatus` | Meaning |
|---------------|---------|
| `uploader` | The uploader's own profile (`isLocal`) |
| `pid` | PID reported by the plugin; `playerId` is `null` if the poller has not seen that player yet |
| `name` | Exactly one player with this Mii name was in the lobby |
| `ambiguous` | Several players with this name were in the lobby - left open |
| `unmatched` | No player with this name was in the lobby (or no lobby found) |
| `guest` | Local co-op guest |

**VR of the other players:** After a VS race (`gameMode` 0) the VR of every player matched
for sure (`matchStatus` `pid` or `name`) is stored as their current VS Race VR, marked
`source: "race_result"`. Guests, ambiguous or unmatched names and the uploader are left out -
the uploader's plugin uploads the exact value from the savegame. These entries count on the
website like any other VR entry, but `GET /api/v1/vr-points/latest` ignores them, so a stranger's
observation never hides a player's own entry from another console. A race reported by several
users stores the VR only once.

**Guests:** A guest whose host is known (`guestOfRank` pointing to a matched player) gets a
session in the lobby, the same entry "add guest" creates by hand (`isGuest`,
`guestNickname`, `guestOfPlayerId`). It shows up in the participant list and the guest
statistics. A guest reported with `pid` (signed in with their own account on the host's
console) is linked to their player profile. The guest keeps this session over further races (matched by host and name) and
leaves together with the host: the poller ends guest sessions whose host is no longer in the
lobby. Race results return `guestOfPlayerId` and `guestOfMiiName` for guests.

**Mii, country and region:** Every Mii is stored once (by the SHA-1 of its data) and returned
as `miiHash` per player. For matched players the latest Mii, `countryId` and `regionId` are kept
on the player (`GET /api/players/:id` returns `miiHash`, `countryId`, `regionId`, and
`miiHistory`: every Mii the person used in a reported race with `firstSeen`, `lastSeen` and
`raceCount`, newest first, across all their Pretendo profiles). `combos` lists the driver/vehicle
combinations with `raceCount` and `lastUsed`, most used first; names for the IDs are in
`MK8_PARTS` in `public/js/app.js`. Values outside the ranges above are dropped, the race is kept.
`GET /api/players/search` returns `miiHash` per person.

**Duplicates:** Everyone in the lobby who runs the plugin uploads the same race. Uploads
with the same participants, placements and points within 90 seconds are merged into one
race. `uploadCount` counts the different users that reported it; a repeated upload of
the same user is not counted twice. A later upload fills in players the first one could
not match, a missing `vrChange` and missing `vrExact`/`vrBeforeExact`.

**Invalid details:** Only a race that is unusable as a whole is rejected with `400`: no players,
more than 12, a placement outside 1-12 or a placement that occurs twice, an unknown `gameMode`.
Anything wrong with a single participant is corrected instead, and the race is stored: an
empty `miiName` becomes `?` (longer than 40 characters is shortened), invalid `points` become
0, invalid `vr`, `vrChange`, `vrExact`, `vrBeforeExact`, `countryId`, `regionId`, `pid`, `miiCreateId`, `miiData` and
`guestOfRank` are dropped, a `pid` reported for several participants is dropped for all of
them, and only the first `isLocal` participant counts as the uploader. Every correction is
listed in `warnings` in the response and in the activity log entry.

**Response:** `201 Created` for a new race, `200 OK` when merged into an existing one.
```json
{
  "success": true,
  "created": true,
  "merged": false,
  "warnings": [],
  "raceRound": {
    "id": 17,
    "raceId": 4711,
    "finishedAt": "2026-09-25T14:39:41.000Z",
    "gameMode": 0,
    "courseId": 27,
    "courseName": "Mario Kart Stadium",
    "playerCount": 12,
    "uploadCount": 1,
    "players": [
      { "rank": 1, "points": 15, "miiName": "Kurt", "playerId": 88, "pid": "1471493879",
        "matchStatus": "name", "vr": 48477, "vrChange": null, "countryId": 49,
        "regionId": 50, "isGuest": false }
    ]
  }
}
```

`raceId` is the lobby the race belongs to, or `null` when the uploader was not found
in any lobby (the race is stored anyway).

### Race Start

Report the course of a race that just started, so the website can show what the
lobby is racing right now. The Aroma plugin calls this at the start of every online race.

**Endpoint:** `POST /api/v1/race-start`

**Authentication:** API Token. The account needs a linked Pretendo profile.

**Request Body:**
```json
{
  "consoleSerial": "FW123456789",
  "courseId": 27,
  "players": [
    { "miiName": "Kaito", "isLocal": true, "vr": 12900.375 },
    { "miiName": "Mira", "pid": 1234567890, "vr": 18200.5 },
    { "miiName": "Wes", "isGuest": true }
  ]
}
```

`courseId` must be a known course ID (see the rankings), otherwise `400`.
`players` (optional, plugin v0.16.00+) are the racers of this race. They are
matched to players like the participants of a result (uploader, PID, otherwise the
Mii name in the lobby); an invalid list is ignored. Everybody else in the lobby is
watching, and after the result everybody is waiting for the next race: see `phase`
in [Get Race Rounds](#get-race-rounds) and `phase`/`racePlayers[].raceStatus`
(`racing`, `watching`, `waiting`) in [Get Active Races](#get-active-races). Both are
only set while such a plugin reports from the lobby (up to 5 minutes after the last
start or result).

`players[].vr` (optional, plugin v0.18.00+) is the racer's VR at the start with its
decimals (0–99999.5). From it the server works out what is at stake: every racer of
`phase.racers` gets `vr` (rating used), `vrSource` (`start` = sent here, `last` = the
player's last known VR, `default` = unknown, 1000 assumed), `win` (VR change as 1st)
and `last` (VR change as last), with the game's formula. The lobby page shows them
as "Was steht auf dem Spiel".

**Response:**
```json
{ "success": true, "raceId": 4711, "courseId": 27, "courseName": "Mario Kart Stadium" }
```

`raceId` is `null` when the uploader was not found in any lobby; nothing is stored then.
The course is kept in memory only: it shows as `current` in
[Get Race Rounds](#get-race-rounds) and as `currentCourse` per lobby in
[Get Active Races](#get-active-races) until the race result arrives or for at most 5 minutes,
and is forgotten on a server restart.

The responses of Race Start and [Race Results Upload](#race-results-upload) also carry
`notices`, the same list as [Event Status](#event-status), so the plugin can show a
race result with the provisional event place right away.

### Time Trial Ghosts

From plugin v0.31.00 the own ghosts of the MK8 savegame (`gs…dat`, one per course) go to
mk8u.de. The game names the file after its content: course, combo, time, lap times, Mii name
and country (see `src/services/ttGhosts.js`); the file starts with `CTG0`.

**Endpoint:** `POST /api/v1/time-trials/ghosts/check` (API Token)
`{ "consoleSerial": "…", "names": ["gs….dat", …] }` → `{ "success": true, "missing": ["gs….dat"] }`
- the file names not uploaded yet for the player of this console.

**Endpoint:** `POST /api/v1/time-trials/ghosts?name=gs….dat&serial=…` (API Token,
`Content-Type: application/octet-stream`, body = the file, up to 1 MB)
→ `201 { "success": true, "id": 12, "created": true, "courseId": 16, "timeMs": 117799 }`
(`200` with `created: false` for a file already stored). `400` for downloaded ghosts (`dg…`),
unknown courses, lap times that do not add up to the time, or a file that is no ghost.

`GET /api/time-trials/ghosts/:id/file` (signed in) downloads the file under its savegame name.
Ranking entries (`GET /api/rankings/track/:id`, `/api/rankings/time/:id`) carry
`ghost: { id, laps: [ms…], recordedAt, url }` when the player uploaded a ghost with exactly
that time.

### Race Telemetry

From plugin v0.30.00 the item box hits and the course of the race no longer come
with the result: about 10 s later the plugin sends them to the race it reported.

**Endpoint:** `POST /api/v1/race-rounds/:id/telemetry` (API Token, only accounts that reported the race)

```json
{
  "karts": [3, 1, 4, 2],
  "items": [{ "p": 1, "item": 8, "rank": 1, "t": 64230, "d": 0, "s": 0, "tb": 2 }],
  "track": { "laps": 3, "t": [1000, 2000, 3000], "p": [[120, 260, 400], [150, 300, 455], [90, 210, null], [140, 290, 430]] }
}
```

`karts[i]` is the finishing rank of kart index `i`; items use the same fields as in the race
result (`p` = kart index). `track.t` is the race time of each sample in ms (one per second),
`track.p[i]` the progress of kart `i` at each sample: lap + share of the lap, times 1000
(0 .. laps * 1000, `null` = unknown). Items are stored when the race has none yet; the course
replaces a stored one only when it has more samples. Response:
`{ "success": true, "itemsStored": 40, "trackStored": true, "warnings": [] }`.

`GET /api/race-rounds/:id/track` returns the stored course as
`{ "laps": 3, "t": [...], "racers": [{ "rank": 1, "p": [...] }] }` (404 without one);
`GET /api/race-rounds/:id` says `hasTrack`.

### Race Screenshot

A screenshot taken in-game during a race with the ScreenshotWUPS plugin. The Aroma
plugin (v0.22.00+) finds the screenshots taken between the race start and shortly
after the result and sends them one by one after the race result.

**Endpoint:** `POST /api/v1/race-rounds/:id/screenshots?screen=tv&takenAt=2026-10-04T21:14:07`

**Authentication:** API Token. Only accounts that reported this race (its
`uploaderIds`) may add screenshots, within 24 hours of the race and up to the
admin setting `race_screenshots_max` per account and race (default 10, at most 50,
`0` switches race screenshots off). The plugin stops at the first `429`.

**Body:** the image file itself (`Content-Type: image/jpeg` or `image/png`, up to 6 MB).
`screen` (`tv` or `drc`) and `takenAt` (console time) are optional and come from the
ScreenshotWUPS file name. The image is re-encoded as JPEG fitting 1280×720.

**Response:** `201` `{ "success": true, "id": 17, "raceRoundId": 4711 }`; `400` when the
body is no JPEG/PNG, `403` for an account that did not report the race, `429` when the
limit is reached.

Signed-in users see the screenshots in `screenshots` of `GET /api/race-rounds/:id`
(file under `/api/race-screenshots/:id/file`); the uploader and admins can delete one
with `DELETE /api/race-screenshots/:id`.

### Time Trial Best

A new personal best from a time trial. The Aroma plugin (v0.17.00+) reads the
finishing time in-game and sends it only when the game itself counts it as a new
record (faster than the saved ghost, under 5:00.000) - the same check as the game's
result screen. 150cc only (Mario Kart 8 on the Wii U has no other time trials).

**Endpoint:** `POST /api/v1/time-trials`

**Authentication:** API Token. The account needs a linked Pretendo profile; the time
is stored for the profile the console uploads for.

**Request Body:**
```json
{
  "consoleSerial": "A1B2C3D4E5F60708",
  "courseId": 40,
  "timeMs": 111234,
  "previousBestMs": 112050,
  "driverId": 4, "bodyId": 29, "tireId": 14, "gliderId": 11, "colorVariant": 0,
  "miiData": "030000…(192 hex characters)"
}
```

`courseId` is a course ID of the rankings (16-63), `timeMs` 30 s to 4:59.999.
`previousBestMs`, the parts and `miiData` are optional. Driver/vehicle (IDs as in race
results) and the Mii (Wii U Mii data, plugin v0.23.00+) are stored with the time and
shown in the rankings; the Mii also becomes the player's picture. A known time that
had no combo yet gets it from a later upload.

**Response:** `201` for a new time, `200` when it is already in the archive:
```json
{ "success": true, "status": "approved", "trackTimeId": 51234, "time": "1:51.234" }
```

`status`: `approved` - shown in the rankings right away (source "plugin");
`pending` - faster than the course's plausibility cutoff (the admin's manual
threshold, or the gap rule over the current top 10 including this time), waits in
Admin → Rankings like a hand-entered time; `known` - this time is already archived.

### Time Trial Import

All saved time trial bests of the console user at once. The Aroma plugin (v0.19.00+)
sends them only when the user starts "Import time trial bests" in the plugin menu;
it reads the saved best of every course (the game's ghost list), up to 48 times.

**Endpoint:** `POST /api/v1/time-trials/import`

**Authentication:** API Token, as for [Time Trial Best](#time-trial-best).

**Request Body:**
```json
{
  "consoleSerial": "A1B2C3D4E5F60708",
  "times": [ { "courseId": 23, "timeMs": 151053 }, { "courseId": 40, "timeMs": 111234 } ]
}
```

Every entry is checked like a single [Time Trial Best](#time-trial-best): already
archived times are skipped, times faster than the plausibility cutoff wait for an
admin (one mail for the whole import). Invalid entries (unknown course, time outside
30 s to 4:59.999, course twice) are counted and skipped. `400` without a list of 1
to 48 entries.

**Response:**
```json
{ "success": true, "approved": 30, "pending": 1, "known": 12, "invalid": 0,
  "summary": "Time trial import: 30 new, 1 wait for approval, 12 already known" }
```

`summary` is the line the plugin shows in-game.

### Time Trial Ghost List

The rankings Mario Kart 8 loads for the ghost download of a course: the top 10 and
the list around the player's own rank. The Aroma plugin (v0.20.00+) reads both from
the game's memory and sends them whenever they change (setting "Upload ghost download
rankings to mk8u.de", on by default).

**Endpoint:** `POST /api/v1/time-trials/ghost-list`

**Authentication:** API Token.

**Request Body:**
```json
{
  "consoleSerial": "A1B2C3D4E5F60708",
  "courseId": 23,
  "top": [ { "pid": 1311404083, "rank": 1, "timeMs": 128517, "miiName": "Kaito" } ],
  "around": [ { "pid": 1402030405, "rank": 57, "timeMs": 131050, "miiName": "Mira" } ]
}
```

The top 10 is already archived from the Pretendo API and is only used as a check: at
least 3 entries with rank 1-10 must be sent, and 70 % of them must match an archived
time (same PID and time). Otherwise nothing is stored (`verified: false`), so a wrong
memory read can never put times into the archive. When the check passes, entries
ranked 11 and below are archived with source `ghost_list` (approved right away,
the rank goes to `bestRank`); known times only get `lastSeenAt` and a better
`bestRank`, and a pending time found in the ranking is approved.

**Response:**
```json
{ "success": true, "verified": true, "compared": 10, "matched": 10,
  "created": 8, "updated": 2, "confirmed": 0,
  "summary": "Ghost list: 8 new, 2 known (check 10/10)" }
```

### Event Status

News of the events the user races in, for the Aroma plugin to show in-game: the
event start, "your lobby is being scored", the result of the latest scored race with
the provisional place, and the final standings. The plugin shows every `key` once and
asks again after `pollSeconds`. Each call marks the user's plugin as running, which is
what the event mods need before they can start scoring a lobby (at least two racers
with the plugin running).

**Endpoint:** `GET /api/v1/events/status`

**Authentication:** API Token.

**Response:**
```json
{
  "success": true,
  "notices": [
    { "key": "e3-start", "text": "Friday Night Frenzy laeuft - such dir eine VS-Lobby!" },
    { "key": "e3-r5512", "text": "Marios Piste: 2. Platz (+12 Pkt.) - vorlaeufig 3. mit 58 Pkt. (6 hinter Yuki)" }
  ],
  "pollSeconds": 60,
  "events": 1
}
```

Texts are in the account's language and plain ASCII (umlauts written as ae/oe/ue).
Only events from 30 minutes before their start until 2 hours after their end count;
`pollSeconds` is 60 while one of them runs, 120 around them, 300 without any.

### Plugin update

The plugin (v0.28.00+) asks at most once a day for the plugin file an admin uploaded
and, when its version is newer, downloads it and replaces its own .wps on the SD card
(only when size and SHA-256 match). Active from the next console start.

**Endpoint:** `GET /api/v1/plugin/latest` (API Token)

```json
{ "success": true, "available": true, "version": "v0.28.00", "size": 412345, "sha256": "9f2c…" }
```

`available` is `false` without an uploaded file or without a version set in the admin area.

**Endpoint:** `GET /api/v1/plugin/download` (API Token) - the .wps file itself
(logged as a plugin download).

---

## Guest Players

Guest players are local co-op players who join races alongside the main player. Guest functionality allows tracking guest player nicknames and linking them to their main player account.

### Guest Player Features

**Nickname History:**
- Player statistics now include comprehensive nickname history from both sources:
  - Pretendo API names (official names from the network)
  - Guest player names (local co-op nicknames)
- Each nickname entry shows:
  - First and last usage timestamps
  - Total usage count
  - Source (Pretendo API or Guest)

**Player Search Enhancement:**
- Player search now includes historical nicknames from both Pretendo API and guest entries
- Searching for a guest nickname will find the associated main player

**Guest Management in the UI:**
- The lobby page lists the guests; participants and admins add, link and remove them there
- The MK8Logger plugin reports guests with their host along with each race result
- The player page lists the names a player was entered under as a guest

### Who May Manage Guests

Adding, linking and removing guests requires a session and is allowed for admins and
for accounts one of whose players was in the lobby. Otherwise `403`.

### Get Guests of a Lobby

**Endpoint:** `GET /api/races/:id/guests`

**Authentication:** Not required

**Response:**
```json
{
  "raceId": "110535",
  "guestCount": 1,
  "guests": [
    {
      "id": 88213,
      "playerId": null,
      "displayName": "Mia",
      "guestNickname": "Mia",
      "guestOfPlayer": { "id": 5, "pid": 1311404083, "miiName": "Achim" },
      "joinedAt": "2026-09-27T18:40:00.000Z",
      "leftAt": null,
      "isActive": true
    }
  ]
}
```

`id` is the session (race player) ID used by the endpoints below. `playerId` is set once
the guest is linked to a player; `displayName` is then that player's Mii name.

### Add a Guest

**Endpoint:** `POST /api/races/:id/add-guest`

**Authentication:** Required (session, see above)

**Request Body:**
```json
{ "guestOfPlayerId": 5, "nickname": "Mia", "playerId": null, "joinedAt": "2026-09-27T18:40:00Z" }
```

- `guestOfPlayerId` (required): the player who brought the guest (the console's owner)
- `nickname` or `playerId` (one of them required): the guest's name, and/or the player
  the guest is known as
- `joinedAt` (optional): default now

**Response:**
```json
{ "success": true, "guest": { "id": 88213, "raceId": 110535, "playerId": null, "guestNickname": "Mia", "isGuest": true, "guestOfPlayerId": 5, "player": null, "guestOfPlayer": { "id": 5, "pid": 1311404083, "miiName": "Achim" } } }
```

The MK8Logger plugin reports guests itself with each race result; this endpoint is for
adding one by hand.

### Link a Guest to a Player

**Endpoint:** `PUT /api/race-players/:id/link-player`

**Authentication:** Required (session, see above)

**Request Body:**
```json
{ "playerId": 12 }
```

**Response:**
```json
{ "success": true, "racePlayer": { "id": 88213, "playerId": 12, "guestNickname": "Mia", "player": { "id": 12, "pid": 1356763971, "miiName": "Dmi" }, "guestOfPlayer": { "id": 5, "pid": 1311404083, "miiName": "Achim" } } }
```

**Errors:**
- `400`: `playerId` missing, or the entry is not a guest
- `404`: session or player not found
- `409` with `"code": "ALREADY_IN_GATHERING"`: the player is already in this lobby
  themselves; `error` holds a message in the page language

### Remove a Guest

**Endpoint:** `DELETE /api/race-players/:id`

**Authentication:** Required (session, see above)

**Response:**
```json
{ "success": true, "message": "Guest player removed" }
```

Only guest entries can be removed (`400` otherwise).

### Get Frequent Guests of a Player

Everyone a player has brought along as a guest, most frequent first - across all of the
person's Pretendo profiles.

**Endpoint:** `GET /api/players/:id/frequent-guests`

**Authentication:** Not required

**Response:**
```json
{
  "playerId": "5",
  "playerName": "Achim",
  "totalGuestSessions": 9,
  "uniqueGuests": 2,
  "frequentGuests": [
    { "playerId": null, "pid": null, "nickname": "Mia", "displayName": "Mia",
      "raceCount": 7, "firstSeen": "2026-08-01T17:00:00.000Z", "lastSeen": "2026-09-27T18:40:00.000Z" }
  ]
}
```

`raceCount` counts sessions (lobbies) with that guest.

### Search Players for a Guest

Players whose Mii name contains the text and who were in at least one lobby, most active
first (max. 10) - to find out whether a guest has a profile of their own.

**Endpoint:** `GET /api/players/search-by-nickname`

**Authentication:** Not required

**Query Parameters:**
- `nickname` (required): at least 2 characters, otherwise the list is empty

**Response:**
```json
{
  "players": [
    { "id": 12, "pid": 1356763971, "miiName": "Dmi", "raceCount": 188, "knownSince": "2026-06-19T10:12:00.000Z" }
  ]
}
```

### Guest Player Data in API Responses

When fetching player statistics, the response includes a `nicknameHistory` field:

**Example from `GET /api/players/:id/stats`:**
```json
{
  "player": {
    "id": 5,
    "pid": "abc123",
    "miiName": "Player1"
  },
  "nicknameHistory": [
    {
      "nickname": "Player1",
      "source": "pretendo",
      "firstUsed": "2025-01-01T10:00:00.000Z",
      "lastUsed": "2025-01-10T11:00:00.000Z",
      "totalCount": 150
    },
    {
      "nickname": "Guest-Player1",
      "source": "guest",
      "firstUsed": "2025-01-05T14:00:00.000Z",
      "lastUsed": "2025-01-08T18:00:00.000Z",
      "totalCount": 25
    }
  ]
}
```

**Nickname History Fields:**
- `nickname`: The nickname used by the player
- `source`: Either "pretendo" (from Pretendo API) or "guest" (from guest player entries)
- `firstUsed`: First time this nickname was seen
- `lastUsed`: Most recent time this nickname was seen
- `totalCount`: Number of times this nickname was used

---

## Error Handling

All API endpoints follow a consistent error handling pattern:

### Success Response

Status Code: `200 OK`

```json
{
  "data": { ... }
}
```

### Error Response

Status Code: `400`, `401`, `403`, `404`, `500`, etc.

```json
{
  "error": "Error message describing what went wrong"
}
```

### Common Status Codes

- `200 OK`: Request successful
- `400 Bad Request`: Invalid request parameters
- `401 Unauthorized`: Authentication required
- `403 Forbidden`: Insufficient permissions
- `404 Not Found`: Resource not found
- `500 Internal Server Error`: Server error

---

## Data Models

### Race Object

```json
{
  "id": 1,
  "gid": 123456,
  "hostPid": "host123",
  "ownerPid": "owner123",
  "gameMode": 0,
  "gameModeName": "VS Race",
  "tournamentId": null,
  "version": "17.17.17.38",
  "region": "Europe",
  "regionCode": 3,
  "dlcCode": 0,
  "dlcName": "No DLC",
  "minParticipants": 2,
  "maxParticipants": 12,
  "playerCount": 8,
  "isActive": true,
  "firstSeen": "2025-01-10T10:30:00.000Z",
  "lastSeen": "2025-01-10T11:00:00.000Z",
  "createdAt": "2025-01-10T10:30:00.000Z",
  "updatedAt": "2025-01-10T11:00:00.000Z"
}
```

### Player Object

```json
{
  "id": 5,
  "pid": "abc123",
  "miiName": "Player1",
  "firstSeen": "2025-01-01T10:00:00.000Z",
  "lastSeen": "2025-01-10T11:00:00.000Z",
  "totalRaces": 150,
  "totalSessions": 200
}
```

### RacePlayer (Session) Object

```json
{
  "id": 1,
  "raceId": 1,
  "playerId": 5,
  "pid": "abc123",
  "miiName": "Player1",
  "joinedAt": "2025-01-10T10:30:00.000Z",
  "leftAt": "2025-01-10T11:00:00.000Z",
  "isActive": false,
  "isHost": true,
  "isOwner": false
}
```

**Session Fields:**
- `joinedAt`: **Session start time** - when the player joined this race
- `leftAt`: Session end time - when the player left (null if still active)
- `isActive`: Whether the player is currently in the race
- `isHost`: Whether the player is the race host
- `isOwner`: Whether the player is the room owner

### Favorite Object

```json
{
  "id": 1,
  "userId": 1,
  "playerId": 5,
  "pid": "abc123",
  "notes": "Good player",
  "notifyOnActive": true,
  "createdAt": "2025-01-10T12:00:00.000Z"
}
```

---

## Game Mode Reference

| ID | Name |
|----|------|
| 0 | VS Race |
| 1 | Battle |
| 2 | Tournament |
| 3 | Friend Room |

---

## Region Reference

The `region` field contains the region name as a string. The `regionCode` field contains the numeric ID.

| Region Code | Region Name |
|-------------|-------------|
| 0 | Worldwide |
| 1 | Japan |
| 2 | America |
| 3 | Europe |
| 4 | Korea |
| 5 | China |
| 6 | Taiwan |

---

## DLC Reference

| DLC Code | Name |
|----------|------|
| 0 | No DLC |
| 1 | DLC 1 |
| 2 | DLC 2 |
| 3 | DLC 1+2 |

---

## Rate Limiting

Currently, there are no strict rate limits enforced. However, please be respectful:
- Limit requests to reasonable intervals
- Cache data when possible
- Use pagination for large datasets

---

## CORS

The API supports Cross-Origin Resource Sharing (CORS) for web applications. For mobile apps, ensure you include appropriate headers in your requests.

---

## Example Usage (JavaScript/Fetch)

### Get Active Races

```javascript
fetch('http://your-server:3000/api/races/active')
  .then(response => response.json())
  .then(data => {
    console.log('Active races:', data.races);
  })
  .catch(error => console.error('Error:', error));
```

### Get Player Sessions (with session start times)

```javascript
const playerPid = 'abc123';
fetch(`http://your-server:3000/api/players/${playerPid}/sessions`)
  .then(response => response.json())
  .then(data => {
    console.log('Player sessions:', data.sessions);
    // Each session includes joinedAt (session start time)
    data.sessions.forEach(session => {
      console.log(`Session started: ${session.joinedAt}`);
      console.log(`Duration: ${session.durationFormatted}`);
    });
  })
  .catch(error => console.error('Error:', error));
```

### Login and Get Favorites

```javascript
// Login
fetch('http://your-server:3000/api/auth/login', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  credentials: 'include', // Important for cookies
  body: JSON.stringify({
    username: 'user',
    password: 'password'
  })
})
.then(response => response.json())
.then(loginData => {
  console.log('Logged in:', loginData);

  // Get favorites (requires authentication)
  return fetch('http://your-server:3000/api/favorites', {
    credentials: 'include' // Include session cookie
  });
})
.then(response => response.json())
.then(favData => {
  console.log('Favorites:', favData.favorites);
})
.catch(error => console.error('Error:', error));
```

### Search Players

```javascript
const searchQuery = 'Player1';
fetch(`http://your-server:3000/api/players/search?q=${encodeURIComponent(searchQuery)}&limit=10`)
  .then(response => response.json())
  .then(data => {
    console.log('Search results:', data.data);
  })
  .catch(error => console.error('Error:', error));
```

### Get Server Downtime Statistics

```javascript
fetch('http://your-server:3000/api/server-status/downtime')
  .then(response => response.json())
  .then(data => {
    console.log(`Uptime: ${data.uptimePercentage}%`);
    console.log(`Total downtime: ${data.totalDowntimeFormatted}`);
    console.log(`Incidents: ${data.incidentCount}`);
    data.incidents.forEach(incident => {
      console.log(`- ${incident.startTime} to ${incident.endTime}: ${incident.durationFormatted}`);
    });
  })
  .catch(error => console.error('Error:', error));
```

### Get Player VR Points

```javascript
const playerId = 5;

// Get latest VR points for all game modes
fetch(`http://your-server:3000/api/vr-points/${playerId}/latest`)
  .then(response => response.json())
  .then(data => {
    console.log('Latest VR Points:', data.vrPoints);
    data.vrPoints.forEach(vr => {
      console.log(`${vr.gameModeName}: ${vr.vrPoints} points`);
    });
  })
  .catch(error => console.error('Error:', error));

// Get complete VR points history
fetch(`http://your-server:3000/api/vr-points/${playerId}`)
  .then(response => response.json())
  .then(data => {
    console.log('VR Points History:', data.vrPoints);
  })
  .catch(error => console.error('Error:', error));
```

### Update VR Points

```javascript
// Login first (VR points update requires authentication)
fetch('http://your-server:3000/api/auth/login', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  credentials: 'include',
  body: JSON.stringify({
    username: 'user',
    password: 'password'
  })
})
.then(response => response.json())
.then(loginData => {
  console.log('Logged in:', loginData);

  // Update VR points for multiple players
  return fetch('http://your-server:3000/api/vr-points', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    credentials: 'include',
    body: JSON.stringify({
      vrPoints: [
        {
          playerId: 5,
          gameMode: 0, // VS Race
          vrPoints: 5100
        },
        {
          playerId: 5,
          gameMode: 1, // Battle
          vrPoints: 4600
        }
      ]
    })
  });
})
.then(response => response.json())
.then(data => {
  console.log('VR Points updated:', data);
  console.log(`Created: ${data.created}, Updated: ${data.updated}`);
})
.catch(error => console.error('Error:', error));
```

### Search Players by Guest Nickname

```javascript
// Search for a player by their guest nickname
const guestName = 'Guest-Player1';
fetch(`http://your-server:3000/api/players/search?miiName=${encodeURIComponent(guestName)}&limit=10`)
  .then(response => response.json())
  .then(data => {
    console.log('Search results (includes historical nicknames):', data.players);
    // Will find the main player even if searching by guest nickname
  })
  .catch(error => console.error('Error:', error));
```

---

## Enhanced Statistics Endpoints

### Get Growth Statistics

Get player growth and trend statistics over time.

**Endpoint:** `GET /api/stats/growth`

**Authentication:** Not required

**Query Parameters:**
- `days` (optional): Time range in days, or 'all' for all-time (default: 30)

**Example Requests:**
```
GET /api/stats/growth?days=30
GET /api/stats/growth?days=7
GET /api/stats/growth?days=all
```

**Response:**
```json
{
  "newPlayersByDay": [
    {
      "date": "2025-01-10",
      "newPlayers": 5
    }
  ],
  "weekOverWeek": {
    "races": {
      "current": 150,
      "previous": 120,
      "growth": 25.0
    },
    "players": {
      "current": 45,
      "previous": 40,
      "growth": 12.5
    }
  }
}
```

### Get Engagement Statistics

Get player engagement and retention metrics.

**Endpoint:** `GET /api/stats/engagement`

**Authentication:** Not required

**Query Parameters:**
- `days` (optional): Time range filter (default: all)

**Response:**
```json
{
  "activePlayers": {
    "today": 25,
    "week": 80,
    "month": 150
  },
  "engagement": [
    {
      "category": "Daily Active",
      "count": 20
    }
  ],
  "avgSessionDuration": 45.5,
  "totalSessions": 5000,
  "avgSessionsPerPlayer": 12.5
}
```

### Get Social Statistics

Get player network and co-player statistics. **Note:** Shows overlap time (actual time played together) instead of race count.

**Endpoint:** `GET /api/stats/social`

**Authentication:** Not required

**Query Parameters:**
- `days` (optional): Time range filter (default: all)

**Response:**
```json
{
  "topDuos": [
    {
      "player1Id": 5,
      "player1Name": "Player1",
      "player1Pid": "abc123",
      "player2Id": 10,
      "player2Name": "Player2",
      "player2Pid": "xyz789",
      "totalOverlapMinutes": 450,
      "racesTogether": 25,
      "lastRaceTogether": "2025-01-10T15:00:00.000Z",
      "avgSessionDurationMinutes": 18
    }
  ],
  "topTrios": [
    {
      "player1Id": 5,
      "player1Name": "Player1",
      "player1Pid": "abc123",
      "player2Id": 10,
      "player2Name": "Player2",
      "player2Pid": "xyz789",
      "player3Id": 15,
      "player3Name": "Player3",
      "player3Pid": "def456",
      "totalOverlapMinutes": 320,
      "racesTogether": 18,
      "lastRaceTogether": "2025-01-09T20:00:00.000Z"
    }
  ],
  "avgCoPlayers": 5.5,
  "connectionMatrix": {
    "players": [...],
    "connections": [...]
  }
}
```

### Get Records Statistics

Get gaming records and extremes.

**Endpoint:** `GET /api/stats/records`

**Authentication:** Not required

**Query Parameters:**
- `days` (optional): Time range filter (default: all)

**Response:**
```json
{
  "longestRace": {
    "id": 42,
    "gid": 123456,
    "playerCount": 8,
    "firstSeen": "2025-01-05T14:00:00.000Z",
    "raceDuration": 7200000
  },
  "mostPlayersRace": {
    "id": 38,
    "gid": 654321,
    "firstSeen": "2025-01-08T18:00:00.000Z",
    "playerCount": 12
  },
  "longestSession": {
    "playerId": 5,
    "miiName": "Player1",
    "pid": "abc123",
    "durationMinutes": 450
  },
  "consistentPlayers": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "activeDays": 28,
      "firstRace": "2025-01-01T10:00:00.000Z",
      "lastRace": "2025-01-30T20:00:00.000Z"
    }
  ]
}
```

### Get Enhanced Leaderboards

Get various leaderboards with time filtering.

**Endpoint:** `GET /api/stats/leaderboards`

**Authentication:** Not required

**Query Parameters:**
- `days` (optional): Time range filter (default: all)
- `period` (optional): For `longestPeriodPlaytime` - 'day' (default), 'week', or 'month'

**Example Requests:**
```
# All-time leaderboards
GET /api/stats/leaderboards

# Last 7 days with daily playtime
GET /api/stats/leaderboards?days=7&period=day

# Last 30 days with weekly playtime
GET /api/stats/leaderboards?days=30&period=week
```

**Response:**
```json
{
  "sessionChampions": [...],
  "nightOwls": [...],
  "earlyBirds": [...],
  "topByRaces": [...],
  "topByPlaytime": [...],
  "topByDiscrepancy": [...],
  "longestSessions": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "raceId": 42,
      "gid": 123456,
      "joinedAt": "2025-01-05T14:00:00.000Z",
      "effectiveLeftAt": "2025-01-05T21:30:00.000Z",
      "durationMinutes": 450
    }
  ],
  "longestPeriodPlaytime": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "period": "2025-01-07",
      "totalMinutes": 520,
      "sessionCount": 12,
      "raceCount": 8
    }
  ],
  "mostNicknames": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "nicknameCount": 8,
      "totalRaces": 150,
      "totalSessions": 200,
      "lastSeen": "2025-01-10T11:00:00.000Z"
    }
  ],
  "playtimePeriod": "day"
}
```

**Leaderboard Descriptions:**
- `sessionChampions`: Most sessions
- `nightOwls`: Most active 22:00-04:00
- `earlyBirds`: Most active 05:00-10:00
- `topByRaces`: Most races
- `topByPlaytime`: Most total playtime
- `topByDiscrepancy`: Largest gap between total and valid playtime
- `longestSessions`: Longest single gaming sessions
- `longestPeriodPlaytime`: Longest playtime in a day/week/month (switchable)
- `mostNicknames`: Players with the most different nicknames (from both Pretendo API and guest entries)

---

## Activity Heatmap

The activity heatmap now shows **weekly averages** instead of peak values:

- **Before**: Total number of races for each day/hour slot across all time
- **After**: Average number of races per week for each day/hour slot

This provides a better view of typical weekly activity patterns regardless of the selected time range.

---

## Support

For questions or issues with the API, please contact the system administrator or open an issue on the project repository.
