---
title: EPG for OTT middleware
url: https://doc.pstreamer.tv/en/manual/web_server_epg.html
lang: en
product: Perfect Streamer
version: 1.13.3.445
---

# EPG for OTT middleware

The server returns electronic programme guide (EPG) data for the selected day for a single channel in JSON format. The endpoint is intended for OTT middleware back-ends that aggregate the schedule from Perfect Streamer to build the programme guide for the end client.

## URL and authentication

The endpoint is served by the built-in admin server. By default it is available on ports `43971` (HTTP) and `43981` (HTTPS); the ports are configured under **Settings/Server Settings**.

```
GET /data/epg/channel?src=<src_id>&ch=<channel_id>&lang=<lang>&t=<time>
```

Authentication is HTTP Digest, like for the rest of `/data/*`. For middleware a `viewer` (read-only) account is sufficient.

> **Note**
>
> Requests from the loopback address (`127.0.0.1`) bypass HTTP Digest verification — the server treats them as anonymous. This is convenient for local scripts and health checks of middleware deployed on the same machine as Perfect Streamer; for remote access credentials are mandatory.

## Request parameters

| Parameter | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `src` | unsigned integer | no | `0` | EPG source. `0` — data imported from MPEG-TS EIT of input streams. `1, 2, …` — entry id from `/config/epg/epg-source` (an external XMLTV source). |
| `ch` | string | **yes** | — | Channel identifier from the EPG database: the value of the `channel_id` field in the `channel` table. The list of available identifiers can be obtained via an SQL query through `POST /data/epg/sql`. |
| `lang` | integer or ISO 639 | no | system default language | `0` — system default language; an integer > 0 — internal language id (see `GET /schema/lang`); a string — a two- or three-letter ISO 639 code, e.g. `eng`, `rus`, `fra`. |
| `t` | unsigned integer (Unix epoch, sec) | no | current server time | Any point inside the day of interest. The server returns events for the **UTC day** that contains `t`: interval `[t / 86400 · 86400, (t / 86400 + 1) · 86400)`. To request data for the «next day» simply add `86400` to the current time. |

> **Note**
>
> The day is taken in UTC, not in the local time zone. If the middleware builds the schedule by the local calendar day, the UTC-day boundary may not coincide with local midnight; in that case make two requests (for adjacent UTC days) and stitch the results by the `start` field.

## Response format

- `Content-Type`: `application/json`.
- HTTP headers forbid caching on the client side and on intermediate proxies:

  ```
  Cache-Control: no-cache, no-store, must-revalidate
  Pragma: no-cache
  Expires: 0
  ```
- Response body is a JSON with the list of events for the day:

```json
{
  "event": [
    {"start": 1715000000, "end": 1715003400, "title": "Morning News", "desc": "Overview of the day's events"},
    {"start": 1715003400, "end": 1715007000, "title": "Weather Forecast", "desc": ""}
  ]
}
```

Event fields:

- `start`, `end` — programme start and end in Unix epoch (sec), UTC.
- `title` — programme title in the selected language. If a title in the requested language is missing for the event, the server falls back to the entry in the system default language, then to any other available one.
- `desc` — extended description. May be an empty string if the database had no separate description for the event.

Output specifics:

- Events with an empty title and no description, as well as events with invalid timestamps, are excluded from the output.
- Duplicates by `start` are dropped: for the same start moment one record is returned with the best language priority (requested → system default → others).
- The order of events in the array is not guaranteed — if needed, the middleware sorts the list by the `start` field on its own.
- If the channel is not found, the source does not exist, or there are no events for the chosen day, the server returns `200 OK` with an empty array `{"event":[]}` or, in the rare case of a missing source, with an empty body. The middleware must handle both variants correctly.

## Caching on the server

The ready JSON is cached by the server under the key `(channel_id, UTC date, language)`, so repeated requests for the same day and channel are served without touching the database. The middleware does not need to manage the cache.

The cache is flushed automatically:

- when new events arrive from MPEG-TS EIT of input streams (`src=0`);
- on a successful refresh of an external XMLTV source (`src > 0` — scheduled or forced via `POST /data/epg/update?s=<src_id>`);
- when a day falls out of the EPG retention window.

A separate endpoint for forced cache flushing is neither provided nor needed.

## HTTP response codes

| Code | Condition |
| --- | --- |
| `200 OK` | Request processed. Body is a JSON with the list of events (possibly empty). |
| `400 Bad Request` | The `ch` parameter is missing or empty; or `src`, `t` cannot be parsed as unsigned integers; or a numeric `lang` points to a non-existent language id. |
| `401 Unauthorized` | HTTP Digest authentication is missing or invalid (for requests not from a loopback address). |

Other situations (unknown `src`, missing channel, no events for the day) do not produce 4xx — the middleware gets `200 OK` with the empty array `{"event":[]}`.

On error, the response body is a fixed-format JSON envelope:

```json
{"status": 400, "message": "Bad Request"}
```

The `status` field duplicates the HTTP code; the `message` field carries a refined error reason in English (or the standard HTTP-status text if no additional information is available). The `Content-Type` of the error response is `application/json`.

## Example

```bash
curl -u middleware:secret --digest \
  'http://pss.example.com:43971/data/epg/channel?src=0&ch=12.0.1&lang=rus&t=1715000000'
```

Sample response:

```json
{
  "event": [
    {"start": 1715000000, "end": 1715003400, "title": "Morning News", "desc": "Overview of the day's events"},
    {"start": 1715003400, "end": 1715007000, "title": "Weather Forecast", "desc": ""}
  ]
}
```

## Performance and scaling

### Perfect Streamer server cache

Inside the PSS process there is an in-memory LRU response cache keyed by `(channel_id, UTC date, language)` with a hard cap of 1024 entries per EPG source. Under typical load (tens–hundreds of channels × 1–3 languages × `keep-day` days) all current entries fit into the cache; repeat requests are served without touching SQLite.

Order of magnitude (debug build, local loopback, no HTTPS):

| Scenario | Latency (single request) | Throughput (P=8) |
| --- | --- | --- |
| Cache hit | ~0.3 ms | ~1100 req/s |
| Cache miss (SQL + JSON) | ~1.0–1.5 ms | ~1000 req/s |

In a release build with debug logging off, the numbers are roughly 2–3× better. Bandwidth — about 14 KB per response for a typical channel-day.

### When an external reverse-proxy (nginx) is needed

The server cache speeds up repeat requests for the same `(channel, day, language)`, but every request still passes through the built-in PSS HTTP server and consumes a thread from its pool. With many clients it makes sense to move caching to the edge:

- **up to ~1,000 online clients** — the internal cache is usually enough, a reverse-proxy is not required.
- **tens of thousands and more** — a caching reverse-proxy (for example, nginx) is recommended. An edge cache handles 99 % of requests without PSS, smooths out peaks (middleware startup, mass player refresh) and allows SSL termination to be moved to a separate node.
- **geographically distributed delivery** — an external CDN/proxy is needed even before counting clients.

PSS sends its own header `Cache-Control: no-cache, no-store, must-revalidate` so that end clients do not cache the EPG for long. The reverse-proxy may (and should) cache the response itself — below it is shown how to tell nginx explicitly to ignore the upstream `Cache-Control` and keep its own TTL.

### Sample nginx configuration

A minimal config for an EPG edge cache aimed at tens of thousands of clients polling every 1–5 minutes:

```nginx
# /etc/nginx/conf.d/pss-epg.conf

proxy_cache_path /var/cache/nginx/pss-epg
                 levels=1:2
                 keys_zone=pss_epg:32m
                 max_size=2g
                 inactive=30m
                 use_temp_path=off;

upstream pss_admin {
    server 127.0.0.1:43971;
    keepalive 64;
}

server {
    listen 80;
    # listen 443 ssl http2;  # recommended: SSL termination here
    server_name epg.example.com;

    # gzip helps: typical EPG JSON compresses ~5–8x.
    gzip on;
    gzip_types application/json;
    gzip_min_length 512;
    gzip_proxied any;

    location = /data/epg/channel {
        proxy_pass http://pss_admin;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # PSS returns no-cache; force-cache it at the edge.
        proxy_ignore_headers Cache-Control Expires Set-Cookie;
        proxy_hide_header   Cache-Control;
        proxy_hide_header   Pragma;
        proxy_hide_header   Expires;

        # Cache key = full URL with query string. The src/ch/lang/t
        # parameters already determine response uniqueness.
        proxy_cache_key     "$scheme$host$request_uri";

        proxy_cache         pss_epg;
        proxy_cache_valid   200 60s;          # staleness TTL
        proxy_cache_valid   400 404 10s;
        proxy_cache_lock           on;        # coalesce cache misses
        proxy_cache_lock_timeout   5s;
        proxy_cache_use_stale      error timeout updating
                                   http_500 http_502 http_503;

        # Serve the JSON to the client with its own TTL
        # (the player won't recheck EPG before that).
        add_header Cache-Control "public, max-age=60";
        add_header X-Cache-Status $upstream_cache_status always;
    }
}
```

### Notes and recommendations

- **TTL ``proxy_cache_valid 200 60s``** — a compromise between EPG freshness and load on the upstream. The programme guide does not change in real time, so 30–300 seconds is reasonable. After importing new events PSS flushes its own cache instantly, and the edge cache catches up at the next TTL.
- **``proxy_cache_lock on``** is mandatory for many clients: on a cache miss it coalesces parallel requests for the same key into a single upstream request, shielding SQLite from peak `BUSY` under load.
- **``keys_zone`` and ``max_size``** are sized by the count of `(channel × day × language)`: 32 MB of `keys_zone` covers hundreds of thousands of keys; 2 GB of `max_size` covers a month of history for hundreds of channels with room to spare.
- **gzip** significantly cuts traffic: responses compress well (repeated JSON keys, Cyrillic in UTF-8).
- **``X-Cache-Status``** in the response lets the middleware see `HIT`/`MISS`/`EXPIRED` and gauge the cache effectiveness.
- If nginx and PSS live on the same machine, the admin server does not require HTTP Digest for loopback, so the upstream block can be left without `proxy_set_header Authorization …`. For a cross-network setup, create a dedicated `viewer` account and add Digest authentication to `proxy_pass`.
- **HTTPS** is best terminated at nginx: PSS supports HTTPS directly, but an edge server is usually more efficient at handling TLS handshakes with thousands of concurrent clients.

## Related endpoints

- `POST /data/epg/sql?s=<src_id>` — arbitrary SQL query against the EPG database (in particular, to obtain a list of `channel_id`).
- `POST /data/epg/update?s=<src_id>` — force-refresh an external XMLTV source.

The complete list and detailed description of the HTTP API are given in `manual/http_data_api.txt`.
