Metadata-Version: 2.4
Name: pybarb
Version: 1.1.0
Summary: Python SDK for Barb APIs and integrations.
Author: Barb
License-Expression: MIT
Project-URL: Documentation, https://documenter.getpostman.com/view/52530320/2sBYAswBkZ
Project-URL: Support, https://support-api.barb.co.uk/
Keywords: sdk,barb,api
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: cryptography>=50.0.0
Requires-Dist: requests>=2.32.5
Requires-Dist: python-dotenv>=1.0.0
Requires-Dist: pandas>=3.0.1
Requires-Dist: pyarrow
Provides-Extra: database
Requires-Dist: SQLAlchemy>=2.0; extra == "database"
Provides-Extra: redis
Requires-Dist: redis>=8.0.0; extra == "redis"

# pybarb

[![PyPI version](https://img.shields.io/pypi/v/pybarb.svg)](https://pypi.org/project/pybarb/)
[![Python Versions](https://img.shields.io/pypi/pyversions/pybarb.svg)](https://pypi.org/project/pybarb/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![PyPI Downloads](https://img.shields.io/pypi/dm/pybarb.svg)](https://pypi.org/project/pybarb/)

A Python SDK for consuming the [BARB API v3](https://documenter.getpostman.com/view/52530320/2sBYAswBkZ#intro), providing typed wrappers around all
metadata and metrics endpoints, and structured error handling.

>  This library lets you pull TV audience data from BARB (Broadcasters' Audience
> Research Board) directly into Python — no manual downloads, no spreadsheets, no API knowledge required.
> You write a few lines of Python code and get back a ready-to-use table of data.

---

## Table of Contents

1. [Before You Begin](#before-you-begin)
   - [Step 1 — Check Python is installed](#step-1--check-python-is-installed)
   - [Step 2 — Set up a Virtual Environment & Install the pybarb library](#step-2--set-up-a-virtual-environment--install-the-pybarb-library)
   - [Step 3 — Configuration](#step-3--configuration)
   - [Step 4 — Connection](#step-4--connection)
     - [Choose a Connection Method](#choose-a-connection-method)
     - [Connect (Recommended)](#connect-recommended)
     - [Persistent Encrypted Token Storage](#persistent-encrypted-token-storage)
       - [Redis Token Storage](#redis-token-storage)
       - [Database Token Storage](#database-token-storage)
     - [Connect with Tokens](#connect-with-tokens)
     - [Automatic Token Refresh](#automatic-token-refresh)
     - [Connect with Refresh Token](#connect-with-refresh-token)
   - [Step 5: Run your first script](#step-5--run-your-first-script)
2. [Quick Start](#quick-start)
3. [Barb API 3.0 Endpoints](#barb-api-30-endpoints)
   - [Metadata Endpoints](#metadata-endpoints)
     - [Stations](#stations)
     - [Viewing Stations](#viewing-stations)
     - [Panels](#panels)
     - [Split Station Factor](#split-station-factor)
     - [Households](#households)
     - [Panel Members](#panel-members)
     - [Spot Schedule](#spot-schedule)
     - [Programme Schedule](#programme-schedule)
     - [Target Audience Categories](#target-audience-categories)
     - [Programme Content Details](#programme-content-details)
     - [Transmission Log Programme Details](#transmission-log-programme-details)
     - [Buyers](#buyers)
     - [Advertisers](#advertisers)
   - [Metrics Endpoints](#metrics-endpoints)
     - [Station Audiences](#station-audiences)
     - [Programme Audiences](#programme-audiences)
     - [Spot Impact](#spot-impact) 
     - [Programme Reach](#programme-reach)   
     - [Spot Reach](#spot-reach)   
   - [Bulk Endpoints](#bulk-endpoints)
     - [Downloading and Loading Data](#downloading-and-loading-data)
     - [Programme Schedule Bulk](#programme-schedule-bulk)
     - [Spot Schedule Bulk](#spot-schedule-bulk)
     - [Programme Ratings Bulk](#programme-ratings-bulk)
     - [Spot Impacts Bulk](#spot-impacts-bulk)
     - [Station Audience Bulk](#station-audience-bulk)
     - [Programme Audience Bulk](#programme-audience-bulk)
     - [Spot Audience Bulk](#spot-audience-bulk)
     - [Viewing Bulk](#viewing-bulk)
4. [Common Use Cases](#common-use-cases)
5. [Troubleshooting](#troubleshooting)
6. [Error Reference](#error-reference)
    - [Connection Errors](#connection-errors)
    - [HTTP Status Errors](#http-status-errors)
    - [Metadata Endpoint Errors](#metadata-endpoint-errors)
    - [Metrics Endpoint Errors](#metrics-endpoint-errors)
7. [Exception Classes](#exception-classes)
8. [FAQ](#faq)
9. [Glossary](#glossary)
10. [Contributing](#contributing)
11. [License](#license)

---

## Who Is This For?

| I am… | This SDK helps me… |
|---|---|
| A **data analyst** | Pull BARB audience data straight into a pandas DataFrame for analysis in Jupyter or Excel |
| A **developer** | Integrate BARB data into dashboards, pipelines, or automated reports |
| A **researcher** | Fetch programme schedules, panel information, and viewing data without manual API work |
| **New to APIs** | Get started with a single `conn.connect()` call after configuring your tokens |

>  **You do not need to understand APIs, HTTP, or JSON to use this library.**  
> You just need Python installed, valid BARB tokens, and the configuration described below.

---

## Before You Begin

>  **Five-minute setup checklist.** Complete these steps once and you'll be ready to
> fetch BARB data in any Python script or notebook you write.

### Step 1 — Check Python is installed

Open your terminal (on Windows: search for **Command Prompt** or **PowerShell**) and type:

```bash
python --version
```

You should see something like `Python 3.12.x`. If you see `command not found` or a version below
3.12, [download Python here](https://www.python.org/downloads/).

**Verification:**
Run `python --version` again after installing to confirm the installation was successful.

---


### Step 2 — Set up a Virtual Environment & Install the pybarb library

A virtual environment keeps the library and its dependencies isolated from other Python projects on your computer. This prevents version conflicts and is highly recommended, especially for beginners.

**1. Create a Virtual Environment:**
Open your terminal and navigate to your project folder:
```bash
cd path/to/your/project
```
Run the following command to create a virtual environment named `.venv`:
```bash
python -m venv .venv
```

**2. Activate the Virtual Environment:**
Before installing anything, you must activate the virtual environment. You need to do this every time you open a new terminal to work on this project.
- **On Windows:**
  ```bash
  .venv\Scripts\activate
  ```
- **On macOS and Linux:**
  ```bash
  source .venv/bin/activate
  ```
Once activated, you should see `(.venv)` at the beginning of your terminal prompt.

**3. Install dependencies and the pybarb library:**
First, install the required dependencies:

```bash
pip install 'cryptography>=44.0.0' requests==2.32.5 python-dotenv pandas==3.0.1 pytest==8.3.3 pyarrow
```

Next, install the library:

```bash
pip install pybarb
```

You only need to do this once.

If the warning **“WARNING: Package(s) not found: pybarb”** is displayed, use the following alternative command:
```bash
pip install pybarb==1.x.x
```
Note : you have to use the latest version from PYPI Release history. 



**Requirements:** Python 3.12+

**Core dependencies:**

| Package         | Version   | Purpose                                 |
|-----------------|-----------|-----------------------------------------|
| `cryptography`  | >= 44.0.0 | Encrypted token storage                 |
| `requests`      | == 2.32.5 | HTTP calls to BARB API                  |
| `pandas`        | == 3.0.1  | DataFrame construction and manipulation |
| `python-dotenv` | Any       | `.env` file support (optional settings) |
| `pytest`        | == 8.3.3  | Testing framework                       |
| `pyarrow`       | Any       | Arrow formatting support                |

**What you should see after a successful install:**

```
Successfully installed pybarb-1.x.x pandas-... requests-...
```

**Verification:**
Run `pip show pybarb` to verify the library is installed successfully.

**Common Errors & Resolutions:**

- **Error:** `Could not find a version that satisfies the requirement pybarb`
  **Resolution:** Make sure you are using a supported Python version (3.12+) and that pip is configured correctly. Try updating pip with `python -m pip install --upgrade pip`.

- **Error:** `'pip3' is not recognized as an internal or external command`
  **Resolution:** Try using `python -m pip install pybarb` instead. If that fails, make sure Python and its Scripts folder are added to your system PATH.

- **Error:** `'pip' is not recognized as an internal or external command`
  **Resolution:** Make sure Python is added to your system PATH. You can check the option "Add Python to PATH" when installing Python on Windows.



---

### Step 3 — Configuration

By default, the SDK connects to the BARB production API.

Connection and logging settings can be supplied through environment variables or a `.env` file.

### Environment Variables

| Variable           | Required | Description                                                          |
|--------------------|----------|----------------------------------------------------------------------|
| `BARB_API_ROOT`    | No       | Optional API root override (default: `https://api.barb.co.uk/api/v3/`) |
| `BARB_REFRESH_TOKEN` | Conditional | Required by `connect()` when the token store is empty |
| `BARB_TOKEN_ENCRYPTION_KEY` | Conditional | Required by the default encrypted file token store |
| `BARB_TOKEN_FILE_PATH` | No | Encrypted token file path (default: `.pybarb/tokens.enc`) |
| `PYBARB_LOG_LEVEL` | No       | Logging level: `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `INFO`) |
| `BARB_LOG_LEVEL`   | No       | Alias for `PYBARB_LOG_LEVEL`                                         |

#### Getting the connection values

`BARB_REFRESH_TOKEN`: obtain the initial token response by following the existing
[BARB Token Creation guide in Postman doc](https://documenter.getpostman.com/view/52530320/2sBYAswBkZ#intro),
then copy the `refresh_token` value into `.env`.

`BARB_TOKEN_ENCRYPTION_KEY`: generate it locally with:

```bash
python -c "from pybarb.auth import generate_token_encryption_key; print(generate_token_encryption_key())"
```

Copy the printed value into `BARB_TOKEN_ENCRYPTION_KEY` in `.env`.

- Treat both values as secrets and never commit them to Git.
- Keep the same encryption key while its encrypted token file is in use. Losing
  or changing the key makes that token file unreadable.
- Generate a separate encryption key for each environment.
- The refresh token comes from BARB. The encryption key is generated locally.

### Example `.env` file

>  **Create a file named `.env`** (note the dot at the start) in your project folder.

```dotenv
BARB_API_ROOT='https://api.barb.co.uk/api/v3/'
BARB_REFRESH_TOKEN='<YOUR_REFRESH_TOKEN>'
BARB_TOKEN_ENCRYPTION_KEY='<YOUR_GENERATED_KEY>'
BARB_TOKEN_FILE_PATH='.pybarb/tokens.enc'
PYBARB_LOG_LEVEL=INFO
```

#### Create your configuration file

The SDK can read settings from a file called **`.env`** (a plain text file whose name starts with a dot). This keeps settings out of your Python code.

**How to create the `.env` file:**

1. Open any plain text editor (Notepad on Windows, TextEdit on Mac, or VS Code).
2. Create a new file.
3. Add the relevant connection settings and optionally the logging level:

```dotenv
BARB_API_ROOT='https://api.barb.co.uk/api/v3/'
BARB_REFRESH_TOKEN='<YOUR_REFRESH_TOKEN>'
BARB_TOKEN_ENCRYPTION_KEY='<YOUR_GENERATED_KEY>'
BARB_TOKEN_FILE_PATH='.pybarb/tokens.enc'
PYBARB_LOG_LEVEL=INFO
```

4. Save the file as **`.env`** — exactly that name, including the dot at the start and no file extension.

**Where to save the `.env` file:**

Save it in the **same folder** as your Python script (`.py` file) or Jupyter notebook.
The library automatically looks for it in the current working directory when your script runs.

**Example folder layout:**

```
my_barb_project/
    .env                  <-- connection settings go here
    fetch_audiences.py    <-- your Python script goes here
```

> **Important — Keep this file private.**
> Do not share the `.env` file, do not email it, and do not commit it to Git.
> If you use Git, add `.env` to your `.gitignore` file so it is never accidentally uploaded.

**Verification:**
Ensure the `.env` file exists in your project directory and is not named `.env.txt`.

---

---

### Step 4 — Connection

Before you can use this SDK, you must obtain an **access token** and a **refresh token**.
These are provided by BARB and are generated by following the document shared by the BARB team.

Token Creation guide: [https://documenter.getpostman.com/view/52530320/2sBYAswBkZ](https://documenter.getpostman.com/view/52530320/2sBYAswBkZ#intro)

Create a `Connection` once, authenticate, then reuse it with all endpoint clients.

### Choose a Connection Method

| Method | Token source | Refresh request | Persistence | Best suited to |
|--------|--------------|-----------------|-------------|----------------|
| `connect()` | Configured token store, then `BARB_REFRESH_TOKEN` when the store is empty | Only when stored tokens need refreshing, or when bootstrapping an empty store | Yes | Normal application use |
| `connect_with_tokens(...)` | Access and refresh tokens supplied by the caller | Only when expiry is unknown, expired, or within 30 seconds | Yes | Tokens obtained manually or from an external authentication system |
| `connect_with_refresh_token(...)` | Refresh token supplied by the caller | Always | Yes | Explicitly starting a new session from a refresh token |

All three methods configure `conn.headers` and save the resulting token set through
the configured `TokenStore`. Their main difference is where the initial tokens come
from and whether an immediate refresh request is required.

### Connect (Recommended)

Use `connect()` for self managed tokens. It initialises using `BARB_REFRESH_TOKEN` from environemnt variable and stores the access/refresh token in the configured token store. Tokens near expiry are refreshed and saved.

```python
from pybarb.connection.connection import Connection

conn = Connection()
conn.connect()
```

The default `FileTokenStore` requires `BARB_TOKEN_ENCRYPTION_KEY`. Production applications can provide a managed implementation through `Connection(token_store=custom_store)` while continuing to use `connect()`.

### Persistent Encrypted Token Storage

`Connection` uses `FileTokenStore` by default. The encrypted file path comes from
`BARB_TOKEN_FILE_PATH`, or defaults to `.pybarb/tokens.enc`. `connect()` loads
tokens from this file first. If it is empty, it uses `BARB_REFRESH_TOKEN` from
`.env` to obtain and save a new token pair. Any token pair returned by a later
refresh is also saved automatically. Older stored token payloads without an
absolute expiry are refreshed once and rewritten in the new format.

> Note:
>
> `FileTokenStore` is suited to local development, desktop applications and
> single-host processes. It is not recommended for production or distributed
> deployments. Use a dedicated secrets manager or another centrally managed
> `TokenStore` implementation with appropriate access control, auditing and key
> management.

The default file store encrypts the complete token payload using
`BARB_TOKEN_ENCRYPTION_KEY`, so this variable is required when using the default
store. Other storage backends can implement `TokenStore` and be passed explicitly
using `Connection(token_store=custom_store)`.

See [Getting the connection values](#getting-the-connection-values) for the key
generation command and storage guidance.

#### Redis Token Storage

Use `RedisTokenStore` when multiple processes need to share the same token set.
Install the optional Redis client dependency with:

```bash
pip install "pybarb[redis]"
```

Pass an official synchronous `redis.Redis` client to the store, then provide the store to `Connection`:

```python
from redis import Redis

from pybarb.auth import RedisTokenStore
from pybarb.connection.connection import Connection

redis_client = Redis.from_url("<YOUR_REDIS_URL>", decode_responses=False)
token_store = RedisTokenStore(
    redis_client,
    token_key="pybarb:tokens:my-application",
)

conn = Connection(token_store=token_store)
conn.connect()
```

Redis token payloads are encrypted using `BARB_TOKEN_ENCRYPTION_KEY` by default.
All processes sharing a token must use the same encryption key. Use a distinct `token_key` for each account or application, and secure the Redis connection with
authentication and TLS where appropriate.

#### Database Token Storage

Use `DatabaseTokenStore` with a synchronous SQLAlchemy engine when token state needs to be shared through an application database. 
Install the optional database dependency with:

```bash
pip install "pybarb[database]"
```

Create a SQLAlchemy engine, then provide the store to `Connection`:

```python
from sqlalchemy import create_engine

from pybarb.auth import DatabaseTokenStore
from pybarb.connection.connection import Connection

engine = create_engine("<YOUR_DATABASE_URL>")
token_store = DatabaseTokenStore(
    engine,
    token_key="pybarb:tokens:my-application",
)

conn = Connection(token_store=token_store)
conn.connect()
```

The store creates a `pybarb_tokens` table and encrypts each token payload using
`BARB_TOKEN_ENCRYPTION_KEY`. All processes sharing a token must use the same
encryption key. Use a distinct `token_key` for each account or application.


### Connect with Tokens

Use this lower-level method when the caller already owns valid access and refresh tokens and knows their remaining lifetime.

`connect_with_tokens()` requires both tokens as explicit arguments. If your tokens are already in the configured token store, call `connect()` instead. When the store is empty, `connect()` uses `BARB_REFRESH_TOKEN` from `.env` to obtain and store a new token pair.

When this lower-level method is called:
- `conn.headers` is set (used automatically by all API clients)
- The token pair and its absolute expiry are saved in the configured token store

If no expiry is known for manually supplied tokens `connect_with_tokens()` refreshes them immediately. It also refreshes tokens that are expired or within 30 seconds of expiry. `expires_in` must represent the remaining lifetime when the
method is called, rather than the token's original lifetime. A successful refresh response must include `expires_in`; the SDK calculates and stores an absolute UTC `expires_at` value alongside `access_token`, `refresh_token` and `expires_in`.


### Automatic Token Refresh

> Note: When using connect() it will automatically refreshes tokens that are expired or within 30 seconds of expiry. Use `conn.ensure_token_valid() only before a loop or a large batch of API requests to guarantee your token never expires mid-execution!

```python
# Call before a long batch of requests
conn.ensure_token_valid()
# Optional: check expiry status
if conn.is_token_expired:
    print("Token expired or near expiry")
```

### Connect with Refresh Token

Use this lower-level method when the caller already owns a refresh token and wants to force an immediate refresh request. Unlike `connect()`, it does not inspect the token store first. Unlike `connect_with_tokens()`, it does not need an access token or expiry value.

```python
from pybarb.connection.connection import Connection

conn = Connection()
conn.connect_with_refresh_token(refresh_token="<YOUR_EXISTING_REFRESH_TOKEN>")

print("Successfully refreshed tokens for a new session.")
```

---

### Step 5 — Run your first script

After completing the `.env` configuration above, create a new file called `test_connection.py` in your project folder and paste in the following code.
The recommended `connect()` method reuses stored tokens or starts a new session from `BARB_REFRESH_TOKEN` when the token store is empty.

```python
from pybarb.connection.connection import Connection
from pybarb.metadata.station import Station

conn = Connection()
conn.connect()

stations = Station(conn).list_stations()
print(f"Connected successfully! {len(stations)} stations available.")
print("First 5 stations:", stations[:5])
```

Save the file, then run it from your terminal:

```bash
python test_connection.py
```

**What you should see:**

```
Connected successfully! 42 stations available.
First 5 stations: ['BBC1', 'BBC2', 'ITV1', 'Channel 4', 'Channel 5']
```

**Verification:**
If you see a list of stations, your connection is verified and you are ready to query data.

If you see this, your setup is complete and you are ready to use all the features described
in the rest of this guide.

**If you see an error instead**, check the [Troubleshooting](#troubleshooting) section for
common problems and how to fix them.

---


## Quick Start

This section shows you how to fetch your first real audience data from BARB in a single script.
It assumes you have already completed the steps in the **Before You Begin** section above.

**What this example does:** Fetches 15-minute audience figures for BBC1 on a specific day
and displays them as a table.

---

### The complete script

Create a new file called `get_bbc1_audiences.py` in your project folder.
Copy and paste all of the following code into it:

```python
# ─────────────────────────────────────────────────────────────────
# get_bbc1_audiences.py
# Fetches BBC1 audience data for a single day and prints a table.
# ─────────────────────────────────────────────────────────────────

# PART 1 — Import the tools we need
# These lines load the pybarb modules into your script.
# You must include them exactly as written — do not change them.
from pybarb.connection.connection import Connection
from pybarb.metadata.station import Station
from pybarb.metrics.station.station_audiences import StationAudiences

# PART 2 - Connect to BARB
# Loads stored tokens or starts a session from BARB_REFRESH_TOKEN.
conn = Connection()
conn.connect()
print("Connected to BARB successfully.")

# PART 3 — Find the station code for BBC1
# BARB uses numeric codes to identify channels internally.
# We look up BBC1's code by name so we don't need to know the number.
station_client = Station(conn)
station_code = station_client.get_station_code("BBC1")
print(f"BBC1 station code: {station_code}")

# PART 4 — Fetch the audience data
# We ask for 15-minute audience slots for BBC1 on 20th July 2023.
# Change the dates below to any date you want to query.
sa = StationAudiences(conn)
df = sa.get_station_audiences_flat_dataframe(
    min_transmission_date="2023-07-20",   # start date (format: YYYY-MM-DD)
    max_transmission_date="2023-07-20",   # end date   (format: YYYY-MM-DD)
    station_code=station_code,            # the station code we looked up above
    panel_code=50,                        # 50 = UK Total panel
    time_period_length=15,                # 15-minute slots
    viewing_status="VOSDAL",             # VOSDAL = same-day viewing (live + recorded same day)
)

# PART 5 — Display the results
print(f"\nTotal rows returned: {len(df)}")
print("\nFirst 5 rows of data:")
print(df.head(5).to_string(index=False))

# PART 6 — (Optional) Save the data to a spreadsheet file
# Uncomment the line below to save all the data to a CSV file you can open in Excel:
# df.to_csv("bbc1_audiences_2023-07-20.csv", index=False)
# print("Data saved to bbc1_audiences_2023-07-20.csv")
```

---

### What each part does (explained simply)

| Part | What it does |
|---|---|
| **Part 1 — Imports** | Loads the pybarb toolkit into your script. Think of it like opening a toolbox before starting a job. |
| **Part 2 — Connect** | Proves to BARB that you are authorised to access data, using your tokens. |
| **Part 3 — Station code** | Looks up the internal number BARB uses to identify BBC1. Every channel has a unique number. |
| **Part 4 — Fetch data** | Sends a request to the BARB API and gets back a table of 15-minute audience figures. |
| **Part 5 — Display** | Prints the first 5 rows so you can see the data immediately. |
| **Part 6 — Save** | (Optional) Saves all the data to a `.csv` file you can open in Excel or Google Sheets. |

---

### How to run it

In your terminal, navigate to the folder containing the script:

```bash
cd C:\Users\YourName\Documents\barb_project
```

Then run:

```bash
python get_bbc1_audiences.py
```

---

### What you will see

```
Connected to BARB successfully.
BBC1 station code: 4934

Total rows returned: 96

First 5 rows of data:
 transmission_date  station_code  panel_code  time_period  audience_size_hundreds   tvr
      2023-07-20          4934          50       06:00:00                      12  0.02
      2023-07-20          4934          50       06:15:00                      18  0.03
      2023-07-20          4934          50       06:30:00                      45  0.08
      2023-07-20          4934          50       06:45:00                      62  0.11
      2023-07-20          4934          50       07:00:00                      98  0.17
```

---

### Understanding the columns

| Column | What it means |
|---|---|
| `transmission_date` | The date the programme was broadcast |
| `station_code` | The internal BARB number for the channel (4934 = BBC1) |
| `panel_code` | The panel the data is from (50 = UK Total) |
| `time_period` | The start time of the 15-minute slot |
| `audience_size_hundreds` | Estimated viewers in hundreds. Value of `98` means approximately **9,800 people** |
| `tvr` | Television Viewing Rating — percentage of the panel that watched. `0.17` means **0.17% of the UK Total panel** |

---

### How to customise the query

To pull data for a different channel, date range, or time slot length, change these lines:

```python
min_transmission_date="2023-07-20",   # change to your start date
max_transmission_date="2023-07-20",   # change to your end date (can be same as start for one day)
station_code=station_client.get_station_code("ITV1"),   # change "BBC1" to any channel name
panel_code=50,                        # 50 = UK Total; use Panels(conn).get_panels() to see all options
time_period_length=30,                # change to 30 for 30-minute slots, or 60 for hourly
viewing_status="CONSOLIDATED",        # change to "CONSOLIDATED" to include catch-up viewing
```

To see all available channel names, run this once:

```python
print(Station(conn).list_stations())
```


## Detailed File Example

<details>
<summary><strong>Click to view the complete Python example</strong></summary>

```python

import gc
import json
import sys
import time
import datetime

from rich import print_json

from pybarb.connection.connection import Connection
from pybarb.utils.logging_config import get_logger

logger = get_logger(__name__)


# ── Shared helper ──────────────────────────────────────────────────────────────

def _section(title: str) -> None:
    """Print a clearly visible section banner."""
    print(f"\n{'=' * 60}\n  {title}\n{'=' * 60}")


# ══════════════════════════════════════════════════════════════════════════════
#  Recommended connection demo
# ══════════════════════════════════════════════════════════════════════════════

def demo_connect() -> None:
    """
    Demonstrate connect() with a live expiry countdown and automatic refresh.

    This uses the configured token store and BARB_REFRESH_TOKEN fallback described
    in the connection setup above.

    Steps demonstrated:
      1. Connect using the recommended Connection.connect() method.
      2. Stored tokens are reused or refreshed as required.
      3. Make an initial API call to prove the token works.
      4. Shorten the token expiry to 35 s for this demonstration only.
      5. Live countdown bar shows remaining token lifetime.
      6. When the token enters the 30-second safety buffer, ensure_token_valid()
         fires automatically, posts a refresh_token grant, and rotates the token.
      7. A second API call proves the refreshed token works.

    """
    from pybarb.metadata.station import Station
    from pybarb.metrics.programme import ProgrammeRatings

    logger.info("=== Demo: connect() + Auto-Refresh ===")

    # ── Establish connection ───────────────────────────────────────────────────
    conn = Connection()
    conn.connect()

    logger.info("Connection established.")
    logger.info("expires_at           : %s", conn._expires_at)
    logger.info("refresh_token present: True")

    # ── API call 1 — prove the token works immediately ─────────────────────────
    logger.info("--- API call 1: Stations endpoint (original token) ---")
    station_client = Station(conn)
    stations = station_client.get_stations()
    logger.info("Stations endpoint returned %d station(s).", len(stations))
    if stations:
        print(f"\nFirst station: {stations[0]}\n")
    del station_client, stations

    # ── Shorten expiry for demo countdown ──────────────────────────────────────
    short_expiry = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=35)
    conn._expires_at = short_expiry
    token_before = conn.headers.get("Authorization", "") if conn.headers else ""

    logger.info(
        "Demo expiry shortened to 35 s (at %s). "
        "Refresh will fire when ≤30 s remain.",
        short_expiry,
    )

    # ── Live countdown ─────────────────────────────────────────────────────────
    print("=" * 60)
    print("  Countdown to token refresh  (buffer = 30 s)")
    print("=" * 60)

    refresh_triggered = False
    while True:
        remaining = (
            conn._expires_at - datetime.datetime.now(datetime.UTC)
        ).total_seconds()
        bar_len   = 30
        fill      = max(0, int((remaining / 35) * bar_len))
        bar       = "█" * fill + "░" * (bar_len - fill)
        sys.stdout.write(
            f"\r  Remaining: {max(0, remaining):5.1f}s  [{bar}]  "
            f"expired={conn.is_token_expired}   "
        )
        sys.stdout.flush()

        if conn.is_token_expired:
            print()  # newline after bar
            logger.info("Token entered expiry buffer — calling ensure_token_valid()...")
            conn.ensure_token_valid()
            refresh_triggered = True
            break

        if remaining <= 0:
            print()
            logger.warning("Token fully expired before refresh was triggered.")
            break

        time.sleep(0.5)

    # ── Refresh results ────────────────────────────────────────────────────────
    token_after = conn.headers.get("Authorization", "") if conn.headers else ""
    print("\n" + "=" * 60)
    logger.info("─── Refresh Results ─────────────────────────────────────")
    logger.info("Refresh triggered    : %s", refresh_triggered)
    logger.info("Token rotated        : %s", token_before != token_after)
    logger.info("expires_at after     : %s", conn._expires_at)
    logger.info("is_token_expired now : %s", conn.is_token_expired)
    if token_before != token_after:
        logger.info("✔ Auto-refresh SUCCESS — new access token obtained via refresh token.")
    else:
        logger.warning("⚠ Token value unchanged — server may reuse tokens.")
    print("=" * 60)

    # ── API call 2 — prove refreshed token works ───────────────────────────────
    logger.info("--- API call 2: Programme Ratings (refreshed token) ---")
    programme_ratings = ProgrammeRatings(conn)
    df = programme_ratings.get_programme_ratings_flat_dataframe(
        min_transmission_date='2026-05-06',
        max_transmission_date='2026-05-06',
        panel_code=50,
        consolidated=False,
        limit=500,
    )
    if not df.empty:
        print(df.head(5).to_string(index=False))
    else:
        print("No programme ratings data returned.")
    del programme_ratings, df
    gc.collect()


# ══════════════════════════════════════════════════════════════════════════════
#  Metadata demos
# ══════════════════════════════════════════════════════════════════════════════

def demo_panels(conn: Connection) -> None:
    """Demo: Panels metadata endpoint."""
    _section("PANELS")
    from pybarb.metadata.panels import Panels
    from pybarb.utils.dataframe_utils import to_dataframe

    panels = Panels(conn)
    panels_data = panels.get_panels()
    panel_df = to_dataframe(panels_data)
    print(panel_df.head(5).to_string(index=False))
    del panels, panels_data, panel_df
    gc.collect()


def demo_stations(conn: Connection) -> None:
    """Demo: Stations metadata endpoint."""
    _section("STATIONS")
    from pybarb.metadata.station import Station
    from pybarb.utils.dataframe_utils import to_dataframe

    stations = Station(conn)
    stations_data = stations.get_stations()
    stations_df = to_dataframe(stations_data)
    print(stations_df.head(5).to_string(index=False))
    del stations, stations_data, stations_df
    gc.collect()


def demo_viewing_stations(conn: Connection) -> None:
    """Demo: Viewing stations metadata endpoint."""
    _section("VIEWING STATIONS")
    from pybarb.metadata.viewing_stations import ViewingStations

    viewing_stations = ViewingStations(conn)
    data = viewing_stations.get_viewing_stations_flat_data_frame()
    print(data.head(5).to_string(index=False))
    del viewing_stations, data
    gc.collect()


def demo_households(conn: Connection) -> None:
    """Demo: Households metadata endpoint."""
    _section("HOUSEHOLDS")
    from pybarb.metadata.households import Households

    households = Households(conn)
    df = households.get_households_flat_dataframe(
        panel_end_date='2025-05-01',
        panel_start_date='2025-01-01',
    )
    print(df.head(5).to_string(index=False))
    del households, df
    gc.collect()


def demo_panel_members(conn: Connection) -> None:
    """Demo: Panel Members metadata endpoint."""
    _section("PANEL MEMBERS")
    from pybarb.metadata.panel_members import PanelMembers

    panelmember = PanelMembers(conn)
    df = panelmember.get_panel_members_flat_dataframe(
        last_updated_greater_than='2025-01-01',
        panel_start_date='2025-01-01',
        panel_end_date='2025-01-01',
        panel_code=1,
    )
    print(df.head(5).to_string(index=False))
    del panelmember, df
    gc.collect()


def demo_target_audience_categories(conn: Connection) -> None:
    """Demo: Target Audience Categories metadata endpoint."""
    _section("TARGET AUDIENCE CATEGORIES")
    from pybarb.metadata.target_audience_categories import TargetAudienceCategories

    tac = TargetAudienceCategories(conn)
    df = tac.get_target_audience_categories_flat_dataframe(
        max_date='2025-01-01',
        min_date='2025-01-01',
        panel_code=[1, 2, 3, 4, 5, 6, 7, 8, 9, 11],
    )
    print(df.head(5).to_string(index=False))
    del tac, df
    gc.collect()


def demo_spot_schedule_metadata(conn: Connection) -> None:
    """Demo: Spot Schedule metadata endpoint."""
    _section("SPOT SCHEDULE (METADATA)")
    from pybarb.metadata.spot_schedule import SpotSchedule

    spotschedule = SpotSchedule(conn)
    df = spotschedule.get_spot_schedule_flat_dataframe(
        max_scheduled_date='2025-01-01',
        min_scheduled_date='2025-01-01',
        station_code='30',
    )
    print(df.head(5).to_string(index=False))
    del spotschedule, df
    gc.collect()


def demo_programme_schedule_metadata(conn: Connection) -> None:
    """Demo: Programme Schedule metadata endpoint."""
    _section("PROGRAMME SCHEDULE (METADATA)")
    from pybarb.metadata.programme_schedule import ProgrammeSchedule

    prg_schedule = ProgrammeSchedule(conn)
    df = prg_schedule.get_programme_schedule_flat_dataframe(
        max_schedule_date='2024-01-01',
        min_schedule_date='2024-01-01',
        station_code=['10', '20'],
    )
    print(df.head(5).to_string(index=False))
    del prg_schedule, df
    gc.collect()


def demo_split_station_factor(conn: Connection) -> None:
    """Demo: Split Station Factor metadata endpoint."""
    _section("SPLIT STATION FACTOR")
    from pybarb.metadata.split_station_factor import SplitStationFactor
    from pybarb.utils.dataframe_utils import to_dataframe

    split_station_factor = SplitStationFactor(conn)
    split_station_factor_data = split_station_factor.get_split_station_factor()
    df = to_dataframe(split_station_factor_data)
    print(df.head(5).to_string(index=False))
    del split_station_factor, split_station_factor_data, df
    gc.collect()


def demo_transmission_log_programme_details(conn: Connection) -> None:
    """Demo: Transmission Log Programme Details metadata endpoint."""
    _section("TRANSMISSION LOG PROGRAMME DETAILS")
    from pybarb.metadata.transmission_log_programme_details import TransmissionLogProgrammeDetails
    from pybarb.utils.dataframe_utils import to_dataframe

    trns_log_prg_details = TransmissionLogProgrammeDetails(conn)
    trans_log_prg_details_data = trns_log_prg_details.list_transmission_log_programme_details('music')
    df = to_dataframe(trans_log_prg_details_data)
    print(df.head(5).to_string(index=False))
    del trns_log_prg_details, trans_log_prg_details_data, df
    gc.collect()


def demo_programme_content_details(conn: Connection) -> None:
    """Demo: Programme Content Details metadata endpoint."""
    _section("PROGRAMME CONTENT DETAILS")
    from pybarb.metadata.programme_content_details import ProgrammeContentDetails
    from pybarb.utils.dataframe_utils import to_dataframe

    prog_cont_details = ProgrammeContentDetails(conn)
    prg_cont_details_data = prog_cont_details.list_programme_content_details('music')
    df = to_dataframe(prg_cont_details_data)
    print(df.head(5).to_string(index=False))
    del prog_cont_details, prg_cont_details_data, df
    gc.collect()


def demo_spot_schedule_arrow(conn: Connection) -> None:
    """Demo: Spot Schedule Arrow stream metadata endpoint."""
    _section("SPOT SCHEDULE ARROW")
    from pybarb.metadata.spot_schedule_arrow import SpotScheduleArrow

    spotschedule_arrow = SpotScheduleArrow(conn)
    try:
        df = spotschedule_arrow.get_spot_schedule_dataframe(
            max_scheduled_date='2025-01-01',
            min_scheduled_date='2025-01-01',
            station_code='30'
        )
        print(df.head(1).to_string(index=False))
        del df
    except Exception as e:
        logger.error("SpotScheduleArrow failed: %s", e)
    del spotschedule_arrow
    gc.collect()


def demo_advertisers(conn: Connection) -> None:
    """Demo: Advertisers metadata endpoint."""
    _section("ADVERTISERS")
    from pybarb.metadata.advertisers import Advertisers

    advertisers = Advertisers(conn)

    # Note: Both min_scheduled_date and max_scheduled_date are optional parameters.
    # If not provided, it will fetch all available advertisers.

    # ── Raw JSON ──────────────────────────────────────────────────────────────
    data = advertisers.get_advertisers(
        min_scheduled_date='2026-08-01',
        max_scheduled_date='2026-08-31'
    )
    print("--- Advertisers JSON ---")
    print_json(data=data)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    df = advertisers.get_advertisers_dataframe(
        min_scheduled_date='2026-08-01',
        max_scheduled_date='2026-08-31'
    )
    print(df.head(5).to_string(index=False))
    
    del data, df, advertisers
    gc.collect()


def demo_advertisers_without_params(conn: Connection) -> None:
    """Demo: Advertisers metadata endpoint without date parameters."""
    _section("ADVERTISERS (NO PARAMS)")
    from pybarb.metadata.advertisers import Advertisers

    advertisers = Advertisers(conn)

    # ── Raw JSON ──────────────────────────────────────────────────────────────
    data = advertisers.get_advertisers()
    print("--- Advertisers JSON (No Params) ---")
    print_json(data=data)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    df = advertisers.get_advertisers_dataframe()
    print(df.head(5).to_string(index=False))
    
    del data, df, advertisers
    gc.collect()


# ══════════════════════════════════════════════════════════════════════════════
#  Metrics demos
# ══════════════════════════════════════════════════════════════════════════════

def demo_spot_impact(conn: Connection) -> None:
    """Demo: Spot Impact metrics endpoint."""
    _section("SPOT IMPACT")
    from pybarb.metrics.spot_impact import SpotImpact

    client = SpotImpact(conn)

    # ── Raw JSON ──────────────────────────────────────────────────────────────
    data = client.get_spot_impact(
        min_transmission_date='2026-05-10',
        max_transmission_date='2026-05-10',
        # station_code=30,
        deduplicate_spots=False,   # True by default; set False to retrieve raw API duplicates
        limit=9999,
        advertiser_name='POSTCODE LOTTERY'
        # is_staggercast_station_code='false',
    )
    print_json(data=data)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    df = client.get_spot_impact_flat_dataframe(
        min_transmission_date='2026-05-10',
        max_transmission_date='2026-05-10',
        # station_code=30,
        deduplicate_spots=False,
        limit=9999,
        advertiser_name='POSTCODE LOTTERY'
        # is_staggercast_station_code='false',
    )
    print(f"Shape: {df.shape}")
    if not df.empty:
        print(df.head(5).to_string(index=False))   # FIX: head(5) only — was full df
        excel_path = "spot_impact_demo3.xlsx"
        df.to_excel(excel_path, index=False)
        print(f"Saved DataFrame to {excel_path}")
    else:
        print("No data available to save.")

    # ── Explicit cleanup ──────────────────────────────────────────────────────
    del data, df, client
    gc.collect()


def demo_station_audiences_metric(conn: Connection) -> None:
    """Demo: Station Audiences metrics endpoint."""
    _section("STATION AUDIENCES (METRIC)")
    from pybarb.metrics.station.station_audiences import StationAudiences

    client = StationAudiences(conn)

    # ── Raw JSON ──────────────────────────────────────────────────────────────
    data = client.get_station_audiences(
        last_updated_greater_than='2023-07-20',
        use_polling_days=True,
        limit=500,
        station_code=4934,
        panel_code=50,
        max_transmission_date='2026-07-24',
        min_transmission_date='2023-07-20',
        time_period_length=15,
        viewing_status='VOSDAL',
    )
    print_json(data=data)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    df = client.get_station_audiences_flat_dataframe(
        last_updated_greater_than='2023-07-20',
        use_polling_days=True,
        limit=500,
        station_code=4934,
        panel_code=50,
        max_transmission_date='2026-07-24',
        min_transmission_date='2023-07-20',
        time_period_length=15,
        viewing_status='VOSDAL',
    )
    print(f"Shape: {df.shape}")
    print(df.head(5).to_string(index=False))

    # ── Explicit cleanup ──────────────────────────────────────────────────────
    del data, df, client
    gc.collect()


def demo_programme_ratings_metric(conn: Connection) -> None:
    """Demo: Programme Ratings metrics endpoint."""
    _section("PROGRAMME RATINGS (METRIC)")
    from pybarb.metrics.programme import ProgrammeRatings

    client = ProgrammeRatings(conn)

    # ── Raw JSON ──────────────────────────────────────────────────────────────
    # FIX: fetch raw JSON once; do NOT re-call the API for the DataFrame version
    data = client.get_programme_ratings(
        min_transmission_date='2026-05-11',
        max_transmission_date='2026-05-11',   # single day
        panel_code=50,
        consolidated=False,
        limit=1000,
    )
    print("--- Programme Ratings JSON ---")
    print_json(data=data)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    df = client.get_programme_ratings_flat_dataframe(
        min_transmission_date='2026-05-11',
        max_transmission_date='2026-05-11',
        panel_code=50,
        consolidated=False,
        limit=1000,
    )
    print(f"Shape: {df.shape}")
    if not df.empty:
        print(df.head(5).to_string(index=False))   # FIX: head(5) only — was full df
        print("Columns:", list(df.columns))
    else:
        print("No data available.")

    # ── Explicit cleanup ──────────────────────────────────────────────────────
    del data, df, client
    gc.collect()


def demo_programme_reach(conn: Connection) -> None:
    """Demo: Programme Reach metrics endpoint."""
    _section("PROGRAMME REACH")
    from pybarb.metrics.programme.programme_reach import ProgrammeReach

    client = ProgrammeReach(conn)

    # ── Raw JSON ──────────────────────────────────────────────────────────────
    data = client.get_programme_reach(
        programme_ids=['BBC News_swmdnz'],
        audience_names=['All Women'],
        start_date='2026-01-01',
        end_date='2026-01-20',
    )
    print("--- Programme Reach JSON ---")
    print_json(data=data)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    df = client.get_programme_reach_flat_dataframe(
        programme_ids=['BBC News_swmdnz'],
        audience_names=['All Women'],
        start_date='2026-01-01',
        end_date='2026-01-20',
    )
    print(f"Shape: {df.shape}")
    if not df.empty:
        print(df.head(5).to_string(index=False))   # FIX: head(5) only — was full df
    else:
        print("No data available.")

    # ── Explicit cleanup ──────────────────────────────────────────────────────
    del data, df, client
    gc.collect()


def demo_spot_reach(conn: Connection) -> None:
    """Demo: Spot Reach metrics endpoint."""
    _section("SPOT REACH")
    from pybarb.metrics.spot.spot_reach import SpotReach

    client = SpotReach(conn)

    # ── Raw JSON ──────────────────────────────────────────────────────────────
    data = client.get_spot_reach(
        clock_numbers=['CCELIVE001030'],
        audience_names=['All Individuals'],
        start_date='2026-01-01',
        end_date='2026-01-01',
    )
    print("--- Spot Reach JSON ---")
    print_json(data=data)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    df = client.get_spot_reach_flat_dataframe(
        clock_numbers=['CCELIVE001030'],
        audience_names=['All Individuals'],
        start_date='2026-01-01',
        end_date='2026-01-01',
    )
    print(f"Shape: {df.shape}")
    if not df.empty:
        print(df.head(5).to_string(index=False))   # FIX: head(5) only — was full df
    else:
        print("No data available.")

    # ── Explicit cleanup ──────────────────────────────────────────────────────
    del data, df, client
    gc.collect()


# ══════════════════════════════════════════════════════════════════════════════
#  Bulk endpoint demos
# ══════════════════════════════════════════════════════════════════════════════

def demo_bulk_programme_schedule(conn: Connection) -> None:
    """Demo: Bulk Programme Schedule endpoint."""
    _section("BULK PROGRAMME SCHEDULE")
    from pybarb.bulk.programme_schedule import ProgrammeSchedule as BulkProgrammeSchedule

    client = BulkProgrammeSchedule(conn)

    # ── DataFrame (URL listing) ────────────────────────────────────────────────
    df = client.get_programme_schedule_dataframe(
        min_scheduled_date='2026-05-01',
        max_scheduled_date='2026-05-31',
        last_updated_greater_than='2026-05-20',
    )
    print(f"Shape: {df.shape}")
    if not df.empty:
        print(df.head(5).to_string(index=False))
    else:
        print("No data available.")

    # ── File download + load ───────────────────────────────────────────────────
    downloaded = client.download_programme_schedule_files(
        min_scheduled_date='2026-05-01',
        max_scheduled_date='2026-05-02',
    )
    print(f"Downloaded {len(downloaded)} file(s)")
    for f in downloaded:
        print(f"  - {f}")

    if downloaded:
        combined_df = client.load_parquet_files_to_dataframe(downloaded)
        print(f"Combined shape: {combined_df.shape}")
        if not combined_df.empty:
            print(combined_df.head(5).to_string(index=False))   # FIX: head(5) only

        flat_df = client.flatten_programme_schedule_dataframe(combined_df)
        print(f"Flattened shape: {flat_df.shape}")
        if not flat_df.empty:
            print(flat_df.head(5).to_string(index=False))   # FIX: head(5) only

        # ── Explicit cleanup ──────────────────────────────────────────────────
        del flat_df, combined_df

    del df, downloaded, client
    gc.collect()


def demo_bulk_station_audiences(conn: Connection) -> None:
    """Demo: Bulk Station Audiences endpoint."""
    _section("BULK STATION AUDIENCES")
    from pybarb.bulk.station_audiences import StationAudiences as BulkStationAudiences

    client = BulkStationAudiences(conn)

    # ── DataFrame (URL listing) ────────────────────────────────────────────────
    df = client.get_station_audiences_dataframe(
        min_transmission_date='2026-05-01',
        max_transmission_date='2026-05-31',
        time_period_length=5,
        viewing_status='live',
    )
    print(f"Shape: {df.shape}")
    if not df.empty:
        print(df.head(5).to_string(index=False))
    else:
        print("No data available.")

    # ── File download + load ───────────────────────────────────────────────────
    sa_downloaded = client.download_station_audiences_files(
        min_transmission_date='2026-05-29',
        max_transmission_date='2026-05-29',
        time_period_length=5,
        viewing_status='live',
    )
    print(f"Downloaded {len(sa_downloaded)} file(s)")
    for f in sa_downloaded:
        print(f"  - {f}")

    if sa_downloaded:
        sa_combined_df = client.load_parquet_files_to_dataframe(sa_downloaded)
        print(f"Combined shape: {sa_combined_df.shape}")
        if not sa_combined_df.empty:
            print(sa_combined_df.head(5).to_string(index=False))   # FIX: head(5) only

        sa_flat_df = client.flatten_station_audiences_dataframe(sa_combined_df)
        print(f"Flattened shape: {sa_flat_df.shape}")
        if not sa_flat_df.empty:
            print(sa_flat_df.head(5).to_string(index=False))   # FIX: head(5) only

        # ── Explicit cleanup ──────────────────────────────────────────────────
        del sa_flat_df, sa_combined_df

    del df, sa_downloaded, client
    gc.collect()


def demo_bulk_spot_impacts(conn: Connection) -> None:
    """Demo: Bulk Spot Impacts endpoint."""
    _section("BULK SPOT IMPACTS")
    from pybarb.bulk.spot_impacts import SpotImpacts

    client = SpotImpacts(conn)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    try:
        df = client.get_spot_impacts_dataframe(
            min_transmission_date='2026-06-27',
            max_transmission_date='2026-06-27',   # single day
        )
        print(f"Shape: {df.shape}")
        if not df.empty:
            print(df.head(5).to_string(index=False))   # FIX: head(5) only
        else:
            print("No data available.")
        del df
    except Exception as e:
        print(f"Failed to fetch Spot Impacts DataFrame: {e}")

    # ── File download + load ───────────────────────────────────────────────────
    si_downloaded = []
    try:
        si_downloaded = client.download_spot_impacts_files(
            min_transmission_date='2026-06-27',
            max_transmission_date='2026-06-27',   # single day
        )
        print(f"Downloaded {len(si_downloaded)} file(s)")
        for f in si_downloaded[:5]:
            print(f"  - {f}")
        if len(si_downloaded) > 5:
            print(f"  ... and {len(si_downloaded) - 5} more.")
    except Exception as e:
        print(f"Failed to download Spot Impacts files: {e}")

    if si_downloaded:
        si_combined_df = client.load_parquet_files_to_dataframe(si_downloaded)
        print(f"Combined shape: {si_combined_df.shape}")
        if not si_combined_df.empty:
            print(si_combined_df.head(5).to_string(index=False))   # FIX: head(5) only

        si_flat_df = client.flatten_spot_impacts_dataframe(si_combined_df)
        print(f"Flattened shape: {si_flat_df.shape}")
        if not si_flat_df.empty:
            print(si_flat_df.head(5).to_string(index=False))   # FIX: head(5) only

        # ── Explicit cleanup ──────────────────────────────────────────────────
        del si_flat_df, si_combined_df

    del si_downloaded, client
    gc.collect()


def demo_bulk_programme_audience(conn: Connection) -> None:
    """Demo: Bulk Programme Audience endpoint."""
    _section("BULK PROGRAMME AUDIENCE")
    from pybarb.bulk.programme_audience import ProgrammeAudience

    client = ProgrammeAudience(conn)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    try:
        df = client.get_programme_audience_dataframe(
            min_session_date='2026-05-01',
            max_session_date='2026-05-30',
            panel_code='927',
        )
        print(f"Shape: {df.shape}")
        if not df.empty:
            print(df.head(5).to_string(index=False))
        else:
            print("No data available.")
        del df
    except Exception as e:
        print(f"Failed to fetch Programme Audience DataFrame: {e}")

    # ── File download + load ───────────────────────────────────────────────────
    pa_downloaded = []
    try:
        pa_downloaded = client.download_programme_audience_files(
            min_session_date='2026-05-01',
            max_session_date='2026-05-30',
            panel_code='927',
        )
        print(f"Downloaded {len(pa_downloaded)} file(s)")
        for f in pa_downloaded[:5]:
            print(f"  - {f}")
        if len(pa_downloaded) > 5:
            print(f"  ... and {len(pa_downloaded) - 5} more.")
    except Exception as e:
        print(f"Failed to download Programme Audience files: {e}")

    if pa_downloaded:
        pa_combined_df = client.load_parquet_files_to_dataframe(pa_downloaded)
        print(f"Combined shape: {pa_combined_df.shape}")
        if not pa_combined_df.empty:
            print(pa_combined_df.head(5).to_string(index=False))   # FIX: head(5) only

        pa_flat_df = client.flatten_programme_audience_dataframe(pa_combined_df)
        print(f"Flattened shape: {pa_flat_df.shape}")
        if not pa_flat_df.empty:
            print(pa_flat_df.head(5).to_string(index=False))   # FIX: head(5) only

        # ── Explicit cleanup ──────────────────────────────────────────────────
        del pa_flat_df, pa_combined_df

    del pa_downloaded, client
    gc.collect()


def demo_bulk_viewing(conn: Connection) -> None:
    """Demo: Bulk Viewing endpoint."""
    _section("BULK VIEWING")
    from pybarb.bulk.viewing import Viewing

    client = Viewing(conn)

    # ── Raw JSON ──────────────────────────────────────────────────────────────
    data = client.get_viewing(
        min_session_date='2026-05-01',
        max_session_date='2026-05-01',
        panel_code='50',
    )
    print(f"Records: {len(data)}")

    # ── DataFrame (URL listing) ────────────────────────────────────────────────
    df = client.get_viewing_dataframe(
        min_session_date='2026-05-01',
        max_session_date='2026-05-01',
        panel_code='50',
    )
    print(f"Shape: {df.shape}")
    if not df.empty:
        print(df.head(5).to_string(index=False))
    else:
        print("No data available.")
    del data, df

    # ── File download + load ───────────────────────────────────────────────────
    downloaded_viewing = client.download_viewing_files(
        min_session_date='2026-05-01',
        max_session_date='2026-05-02',
        panel_code='50',
    )
    print(f"Downloaded {len(downloaded_viewing)} file(s)")
    for f in downloaded_viewing:
        print(f"  - {f}")

    if downloaded_viewing:
        combined_viewing_df = client.load_parquet_files_to_dataframe(downloaded_viewing)
        print(f"Combined shape: {combined_viewing_df.shape}")
        if not combined_viewing_df.empty:
            print(combined_viewing_df.head(5).to_string(index=False))   # FIX: head(5) only

        # FIX: sample before flattening to avoid cartesian-product memory explosion
        # (PANEL_VIEWERS × PROGRAMMES_VIEWED × SPOTS_VIEWED can multiply rows 10–30×)
        sample_for_flatten = combined_viewing_df.head(200)
        flat_viewing_df = client.flatten_viewing_dataframe(sample_for_flatten)
        print(f"Flattened shape (sample of 200 rows): {flat_viewing_df.shape}")
        print(f"Flattened columns ({len(flat_viewing_df.columns)}):")
        for col in flat_viewing_df.columns:
            print(f"  - {col}")
        print("Sample flattened data:")
        print(flat_viewing_df.head(5).to_string(index=False))   # FIX: head(5) only

        # ── Explicit cleanup ──────────────────────────────────────────────────
        del flat_viewing_df, sample_for_flatten, combined_viewing_df

    del downloaded_viewing, client
    gc.collect()


def demo_bulk_spot_schedule(conn: Connection) -> None:
    """Demo: Bulk Spot Schedule endpoint."""
    _section("BULK SPOT SCHEDULE")
    from pybarb.bulk.spot_schedule import SpotSchedule as BulkSpotSchedule

    client = BulkSpotSchedule(conn)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    try:
        df = client.get_spot_schedule_dataframe(
            min_scheduled_date='2026-04-29',
            max_scheduled_date='2026-05-29',
        )
        print(f"Shape: {df.shape}")
        if not df.empty:
            print(df.head(5).to_string(index=False))   # FIX: head(5) only
        else:
            print("No data available.")
        del df
    except Exception as e:
        print(f"Failed to fetch Spot Schedule DataFrame: {e}")

    # ── File download + load ───────────────────────────────────────────────────
    ss_downloaded = []
    try:
        ss_downloaded = client.download_spot_schedule_files(
            min_scheduled_date='2026-04-29',
            max_scheduled_date='2026-04-29',   # already single day
        )
        print(f"Downloaded {len(ss_downloaded)} file(s)")
        for f in ss_downloaded[:5]:
            print(f"  - {f}")
        if len(ss_downloaded) > 5:
            print(f"  ... and {len(ss_downloaded) - 5} more.")
    except Exception as e:
        print(f"Failed to download Spot Schedule files: {e}")

    if ss_downloaded:
        ss_combined_df = client.load_parquet_files_to_dataframe(ss_downloaded)
        print(f"Combined shape: {ss_combined_df.shape}")
        if not ss_combined_df.empty:
            print(ss_combined_df.head(5).to_string(index=False))   # FIX: head(5) only

        ss_flat_df = client.flatten_spot_schedule_dataframe(ss_combined_df)
        print(f"Flattened shape: {ss_flat_df.shape}")
        if not ss_flat_df.empty:
            print(ss_flat_df.head(5).to_string(index=False))   # FIX: head(5) only

        # ── Explicit cleanup ──────────────────────────────────────────────────
        del ss_flat_df, ss_combined_df

    del ss_downloaded, client
    gc.collect()


def demo_bulk_programme_ratings(conn: Connection) -> None:
    """Demo: Bulk Programme Ratings endpoint."""
    _section("BULK PROGRAMME RATINGS")
    from pybarb.bulk.programme_ratings import ProgrammeRatings as BulkProgrammeRatings

    client = BulkProgrammeRatings(conn)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    try:
        df = client.get_programme_ratings_dataframe(
            min_transmission_date='2026-05-11',
            max_transmission_date='2026-05-14',
        )
        print(f"Shape: {df.shape}")
        if not df.empty:
            print(df.head(5).to_string(index=False))   # FIX: head(5) only
        else:
            print("No data available.")
        del df
    except Exception as e:
        print(f"Failed to fetch Programme Ratings DataFrame: {e}")

    # ── File download + load ───────────────────────────────────────────────────
    pr_downloaded = []
    try:
        pr_downloaded = client.download_programme_ratings_files(
            min_transmission_date='2026-05-11',
            max_transmission_date='2026-05-11',   # already single day
        )
        print(f"Downloaded {len(pr_downloaded)} file(s)")
        for f in pr_downloaded[:5]:
            print(f"  - {f}")
        if len(pr_downloaded) > 5:
            print(f"  ... and {len(pr_downloaded) - 5} more.")
    except Exception as e:
        print(f"Failed to download Programme Ratings files: {e}")

    if pr_downloaded:
        pr_combined_df = client.load_parquet_files_to_dataframe(pr_downloaded)
        print(f"Combined shape: {pr_combined_df.shape}")
        if not pr_combined_df.empty:
            print(pr_combined_df.head(5).to_string(index=False))   # FIX: head(5) only

        pr_flat_df = client.flatten_programme_ratings_dataframe(pr_combined_df)
        print(f"Flattened shape: {pr_flat_df.shape}")
        if not pr_flat_df.empty:
            print(pr_flat_df.head(5).to_string(index=False))   # FIX: head(5) only

        # ── Explicit cleanup ──────────────────────────────────────────────────
        del pr_flat_df, pr_combined_df

    del pr_downloaded, client
    gc.collect()


def demo_bulk_spot_audience(conn: Connection) -> None:
    """Demo: Bulk Spot Audience endpoint."""
    _section("BULK SPOT AUDIENCE")
    from pybarb.bulk.spot_audience import SpotAudience as BulkSpotAudience

    client = BulkSpotAudience(conn)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    try:
        df = client.get_spot_audience_dataframe(
            min_session_date='2026-02-01',
            max_session_date='2026-02-01',   # single day
            panel_code='48',
        )
        print(f"Shape: {df.shape}")
        if not df.empty:
            print(df.head(5).to_string(index=False))   # FIX: head(5) only
        else:
            print("No data available.")
        del df
    except Exception as e:
        print(f"Failed to fetch Spot Audience DataFrame: {e}")

    # ── File download + load ───────────────────────────────────────────────────
    bulk_sa_downloaded = []
    try:
        bulk_sa_downloaded = client.download_spot_audience_files(
            min_session_date='2026-02-01',
            max_session_date='2026-02-01',   # single day
            panel_code='48',
        )
        print(f"Downloaded {len(bulk_sa_downloaded)} file(s)")
        for f in bulk_sa_downloaded[:5]:
            print(f"  - {f}")
        if len(bulk_sa_downloaded) > 5:
            print(f"  ... and {len(bulk_sa_downloaded) - 5} more.")
    except Exception as e:
        print(f"Failed to download Spot Audience files: {e}")

    if bulk_sa_downloaded:
        bulk_sa_combined_df = client.load_parquet_files_to_dataframe(bulk_sa_downloaded)
        print(f"Combined shape: {bulk_sa_combined_df.shape}")
        if not bulk_sa_combined_df.empty:
            print(bulk_sa_combined_df.head(5).to_string(index=False))   # FIX: head(5) only

        bulk_sa_flat_df = client.flatten_spot_audience_dataframe(bulk_sa_combined_df)
        print(f"Flattened shape: {bulk_sa_flat_df.shape}")
        if not bulk_sa_flat_df.empty:
            print(bulk_sa_flat_df.head(5).to_string(index=False))   # FIX: head(5) only

        # ── Explicit cleanup ──────────────────────────────────────────────────
        del bulk_sa_flat_df, bulk_sa_combined_df

    del bulk_sa_downloaded, client
    gc.collect()


# ══════════════════════════════════════════════════════════════════════════════
#  Entry point
# ══════════════════════════════════════════════════════════════════════════════

def main() -> None:
    """
    Main demo runner.

    Memory strategy
    ───────────────
    • Each endpoint is wrapped in its own function so all local variables go
      out of scope automatically when the function returns.
    • Every function also calls `del` on its largest objects and then
      `gc.collect()` to prompt the garbage collector immediately.
    • Only `head(5)` is ever printed — never the full DataFrame.
    • Original date ranges from the initial implementation are preserved.
      Comment out sections you do not need to reduce total memory at runtime.
    """
    try:
        # ------------------------------------------------------------------
        # CONNECTION
        # ------------------------------------------------------------------
        # Token connection - reads BARB_REFRESH_TOKEN from .env
        conn = Connection()
        conn.connect()
        # demo_connect()                    # demonstrate automatic refresh

        # ------------------------------------------------------------------
        # METADATA ENDPOINTS
        # ------------------------------------------------------------------
        # demo_panels(conn)
        # demo_stations(conn)
        # demo_viewing_stations(conn)
        # demo_households(conn)
        # demo_panel_members(conn)
        # demo_target_audience_categories(conn)
        # demo_spot_schedule_metadata(conn)
        # demo_programme_schedule_metadata(conn)
        # demo_split_station_factor(conn)
        # demo_transmission_log_programme_details(conn)
        # demo_programme_content_details(conn)
        # demo_advertisers(conn)
        # demo_advertisers_without_params(conn)

        # ------------------------------------------------------------------
        # METRICS ENDPOINTS
        # ------------------------------------------------------------------
        # demo_spot_impact(conn)
        # demo_station_audiences_metric(conn)
        # demo_programme_ratings_metric(conn)
        # demo_programme_reach(conn)
        # demo_spot_reach(conn)

        # ------------------------------------------------------------------
        # BULK ENDPOINTS
        # ------------------------------------------------------------------
        # demo_bulk_programme_schedule(conn)
        # demo_bulk_station_audiences(conn)
        # demo_bulk_spot_impacts(conn)
        # demo_bulk_programme_audience(conn)
        # demo_bulk_viewing(conn)
        # demo_bulk_spot_schedule(conn)
        # demo_bulk_programme_ratings(conn)
        # demo_bulk_spot_audience(conn)

    except FileNotFoundError as e:
        logger.error("Error: %s", e)
    except json.JSONDecodeError:
        logger.error("Error: Failed to decode creds.json. Ensure it's valid JSON.")
    except Exception:
        logger.exception("Unexpected error occurred")


if __name__ == '__main__':
    main()


```

</details>

---




## Barb API 3.0 Endpoints

### Metadata Endpoints

**Folder:** `pybarb/metadata/` — Use modules in this folder to access Metadata APIs.

>  "Metadata" means **reference information** — the lists of stations,
> panels, programmes, schedules, and households that describe the BARB universe.
> Think of it as the "lookup tables" you need before you can make sense of audience numbers.
>
> **Every metadata endpoint works the same way:**
> 1. Create a client object (e.g. `Station(conn)`)
> 2. Call a method to get the data (e.g. `get_stations()`)
> 3. Optionally, get back a flat table (DataFrame) using the `_flat_dataframe()` variant

All metadata classes follow the same pattern:

- Accept a connected `Connection` object in their constructor.
- Raise `ApiError` on validation failures or bad API responses.
- Provide a `get_*()` method returning raw `list[dict]` and a `get_*_flat_dataframe()` method
  returning a normalised `pd.DataFrame`.

---

#### Stations

**File to import:** `pybarb.metadata.station`

>  TV channels / broadcast stations (e.g. BBC1, ITV, Channel 4). Use this to look up the
> numeric station code you'll need when fetching audience data.

**API endpoint:** `GET /meta/stations`

```python
from pybarb.metadata.station import Station

station_client = Station(conn)
```

| Method                             | Returns                | Description                                         |
|------------------------------------|------------------------|-----------------------------------------------------|
| `get_stations()`                   | `list[dict[str, Any]]` | Returns the full station list                       |
| `list_stations(regex_filter=None)` | `list[str]`            | Returns station names, optionally filtered by regex |
| `get_station_code(station_name)`   | `int \| str`           | Returns the station code for an exact name match    |

```python
stations_data = station_client.get_stations()
bbc_stations  = station_client.list_stations(regex_filter="^BBC")
code          = station_client.get_station_code("BBC1")
```

---

#### Viewing Stations

**File to import:** `pybarb.metadata.viewing_stations`

>  Viewing stations represent the channels as **viewers** see them (which may differ slightly
> from broadcast stations due to regional splits and platform variations).

**API endpoint:** `GET /meta/viewing-stations`

```python
from pybarb.metadata.viewing_stations import ViewingStations

vs = ViewingStations(conn)
```

| Method                                     | Returns                | Description                                        |
|--------------------------------------------|------------------------|----------------------------------------------------|
| `get_viewing_stations()`                   | `list[dict[str, Any]]` | Returns all viewing stations                       |
| `list_viewing_stations(regex_filter=None)` | `list[dict[str, Any]]` | Returns stations optionally filtered by name regex |
| `get_viewing_stations_flat_data_frame()`   | `pd.DataFrame`         | Returns all viewing stations as a flat DataFrame   |

```python
df            = vs.get_viewing_stations_flat_data_frame()
bbc1_stations = vs.list_viewing_stations("BBC1 Midlands")
```

---

#### Panels

**File to import:** `pybarb.metadata.panels`

>  A **panel** is a representative sample of UK households whose TV viewing is measured by BARB.
> Different panels cover different regions (e.g. London, Scotland, Wales). Each panel has a code
> (e.g. `50` for the UK Total panel) that you'll need when fetching audience metrics.

**API endpoint:** `GET /meta/panels`

```python
from pybarb.metadata.panels import Panels

panels_client = Panels(conn)
```

| Method                           | Returns                | Description                                        |
|----------------------------------|------------------------|----------------------------------------------------|
| `get_panels()`                   | `list[dict[str, Any]]` | Returns all panel records                          |
| `list_panels(regex_filter=None)` | `list[dict[str, Any]]` | Returns panels optionally filtered by region regex |
| `get_panel_code(panel_region)`   | `str`                  | Returns the panel code for an exact region match   |

```python
all_panels = panels_client.get_panels()
code       = panels_client.get_panel_code("London - ITV,C4,ITV Breakfast")
```

---

#### Split Station Factor

**File to import:** `pybarb.metadata.split_station_factor`

>  Some stations broadcast the same content to multiple regions simultaneously. The
> **split station factor** adjusts audience numbers to account for this, so figures
> are not double-counted across regional breakdowns.

**API endpoint:** `GET /meta/split-station-factors`

```python
from pybarb.metadata.split_station_factor import SplitStationFactor

ssf = SplitStationFactor(conn)
```

| Method                                         | Returns                | Description                                               |
|------------------------------------------------|------------------------|-----------------------------------------------------------|
| `get_split_station_factor()`                   | `list[dict[str, Any]]` | Returns all split station factor records                  |
| `list_split_station_factor(regex_filter=None)` | `list[dict[str, Any]]` | Returns records optionally filtered by station name regex |

```python
data = ssf.list_split_station_factor("ITV Border England")
```

---

#### Households

**File to import:** `pybarb.metadata.households`

>  Details about the **households** in BARB's measurement panels — including the types of
> TV-connected devices each household has. Useful for understanding the composition of the panel.

**API endpoint:** `GET /meta/households`

```python
from pybarb.metadata.households import Households

hh = Households(conn)
```

| Method                                                  | Returns        | Description                                                     |
|---------------------------------------------------------|----------------|-----------------------------------------------------------------|
| `get_households(panel_start_date, panel_end_date, ...)`  | `list[dict]`   | Raw household records                                           |
| `get_households_flat_dataframe(panel_start_date, ...)`   | `pd.DataFrame` | Flattened household DataFrame (devices list expanded into rows) |

**Parameters:**

| Parameter                   | Type  | Required | Description                               |
|-----------------------------|-------|----------|-------------------------------------------|
| `panel_start_date`          | `str` | Yes      | Start date in `YYYY-MM-DD` format         |
| `panel_end_date`            | `str` | Yes      | End date in `YYYY-MM-DD` format           |
| `last_updated_greater_than` | `str` | No       | ISO datetime filter for incremental loads |
| `panel_code`                | `str` | No       | Filter by panel code                      |
| `panel_region`              | `str` | No       | Filter by panel region                    |

```python
df = hh.get_households_flat_dataframe(
    panel_start_date="2025-01-01",
    panel_end_date="2025-05-01",
)
print(df.head(5).to_string(index=False))
```

---

#### Panel Members

**File to import:** `pybarb.metadata.panel_members`

>  The **individual people** within BARB panel households, along with their demographic weights.
> Weights are used to scale up panel results to represent the full UK population.

**API endpoint:** `GET /meta/panel-members`

```python
from pybarb.metadata.panel_members import PanelMembers

pm = PanelMembers(conn)
```

| Method                                                     | Returns        | Description                                                          |
|------------------------------------------------------------|----------------|----------------------------------------------------------------------|
| `get_panel_members(panel_start_date, panel_end_date, ...)`  | `list[dict]`   | Raw panel member records                                             |
| `get_panel_members_flat_dataframe(panel_start_date, ...)`   | `pd.DataFrame` | Flattened DataFrame (`panel_member_weights` list expanded into rows) |

**Parameters:**

| Parameter                   | Type  | Required | Description                               |
|-----------------------------|-------|----------|-------------------------------------------|
| `panel_start_date`          | `str` | Yes      | Start date in `YYYY-MM-DD` format         |
| `panel_end_date`            | `str` | Yes      | End date in `YYYY-MM-DD` format           |
| `last_updated_greater_than` | `str` | No       | ISO datetime filter for incremental loads |
| `panel_code`                | `str` | No       | Filter by panel code                      |
| `panel_region`              | `str` | No       | Filter by panel region                    |

```python
df = pm.get_panel_members_flat_dataframe(
    panel_start_date="2025-01-01",
    panel_end_date="2025-01-01",
    panel_code="1",
)
```

---

#### Spot Schedule

**File to import:** `pybarb.metadata.spot_schedule`

>  The **spot schedule** lists all TV advertising spots (commercial breaks) that were
> scheduled to air on a given day and channel. Each "spot" is a single advertisement placement.

**API endpoint:** `GET /meta/spot/schedules`

```python
from pybarb.metadata.spot_schedule import SpotSchedule

ss = SpotSchedule(conn)
```

| Method                                                           | Returns        | Description                       |
|------------------------------------------------------------------|----------------|-----------------------------------|
| `get_spot_schedule(min_scheduled_date, max_scheduled_date, ...)` | `list[dict]`   | Raw spot schedule records         |
| `get_spot_schedule_flat_dataframe(min_scheduled_date, ...)`      | `pd.DataFrame` | Flattened spot schedule DataFrame |

**Parameters:**

| Parameter                   | Type  | Required | Description                               |
|-----------------------------|-------|----------|-------------------------------------------|
| `min_scheduled_date`        | `str` | Yes      | Start date in `YYYY-MM-DD` format         |
| `max_scheduled_date`        | `str` | Yes      | End date in `YYYY-MM-DD` format           |
| `station_code`              | `str` | No       | Filter by station code                    |
| `last_updated_greater_than` | `str` | No       | ISO datetime filter for incremental loads |

```python
df = ss.get_spot_schedule_flat_dataframe(
    min_scheduled_date="2025-01-01",
    max_scheduled_date="2025-01-01",
    station_code="30",
)
```

---

#### Programme Schedule

**File to import:** `pybarb.metadata.programme_schedule`

>  The **programme schedule** shows what programmes were broadcast, on which channel, and when.
> Think of it as the TV listings guide, but in data form.

**API endpoint:** `GET /meta/programme/schedules`

```python
from pybarb.metadata.programme_schedule import ProgrammeSchedule

ps = ProgrammeSchedule(conn)
```

| Method                                                              | Returns        | Description                                                               |
|---------------------------------------------------------------------|----------------|---------------------------------------------------------------------------|
| `get_programme_schedule(max_schedule_date, min_schedule_date, ...)`  | `list[dict]`   | Raw programme schedule records                                            |
| `get_programme_schedule_flat_dataframe(max_schedule_date, ...)`      | `pd.DataFrame` | Flattened schedule DataFrame (`station_schedule` list expanded into rows) |

**Parameters:**

| Parameter                   | Type               | Required | Description                               |
|-----------------------------|--------------------|----------|-------------------------------------------|
| `max_schedule_date`         | `str`              | Yes      | End date in `YYYY-MM-DD` format           |
| `min_schedule_date`         | `str`              | Yes      | Start date in `YYYY-MM-DD` format         |
| `station_code`              | `str \| list[str]` | No       | Single code or list of codes              |
| `last_updated_greater_than` | `str`              | No       | ISO datetime filter for incremental loads |

```python
df = ps.get_programme_schedule_flat_dataframe(
    max_schedule_date="2024-01-01",
    min_schedule_date="2024-01-01",
    station_code=["10", "20"],
)
```

---

#### Target Audience Categories

**File to import:** `pybarb.metadata.target_audience_categories`

>  **Audience categories** are demographic groupings (e.g. adults 16–34, housewives with children)
> that BARB uses to report viewing figures. This endpoint returns the category definitions
> for a given panel and date range.

**API endpoint:** `GET /meta/target-audience-categories`

```python
from pybarb.metadata.target_audience_categories import TargetAudienceCategories

tac = TargetAudienceCategories(conn)
```

| Method                                                           | Returns        | Description                    |
|------------------------------------------------------------------|----------------|--------------------------------|
| `get_target_audience_categories(max_date, min_date, panel_code)` | `list[dict]`   | Raw category records           |
| `get_target_audience_categories_flat_dataframe(max_date, ...)`   | `pd.DataFrame` | Flattened categories DataFrame |

**Parameters:**

| Parameter    | Type                             | Required | Description                                    |
|--------------|----------------------------------|----------|------------------------------------------------|
| `max_date`   | `str`                            | Yes      | End date in `YYYY-MM-DD` format                |
| `min_date`   | `str`                            | Yes      | Start date in `YYYY-MM-DD` format              |
| `panel_code` | `int \| str \| list[int \| str]` | Yes      | Up to 10 panel codes (list or comma-separated) |

```python
df = tac.get_target_audience_categories_flat_dataframe(
    max_date="2025-01-01",
    min_date="2025-01-01",
    panel_code=[1, 2, 3, 4, 5],
)
```

---

#### Programme Content Details

**File to import:** `pybarb.metadata.programme_content_details`

>  Search for programmes by name to find their BARB content identifiers.
> The search string must be at least 3 characters long.

**API endpoint:** `GET /meta/programme/content-details`

```python
from pybarb.metadata.programme_content_details import ProgrammeContentDetails

pcd = ProgrammeContentDetails(conn)
```

| Method                                          | Parameters                         | Returns      | Description                                |
|-------------------------------------------------|------------------------------------|--------------|--------------------------------------------|
| `list_programme_content_details(search_string)` | `search_string: str` (min 3 chars) | `list[dict]` | Searches programme content details by name |

```python
results = pcd.list_programme_content_details("music")
```

---

#### Transmission Log Programme Details

**File to import:** `pybarb.metadata.transmission_log_programme_details`

>  Similar to Programme Content Details, but searches the **transmission log** — the record
> of what was actually broadcast (as opposed to what was scheduled).

**API endpoint:** `GET /meta/transmission-log/programme-details`

```python
from pybarb.metadata.transmission_log_programme_details import TransmissionLogProgrammeDetails

tlpd = TransmissionLogProgrammeDetails(conn)
```

| Method                                                   | Parameters                         | Returns      | Description                                 |
|----------------------------------------------------------|------------------------------------|--------------|---------------------------------------------|
| `list_transmission_log_programme_details(search_string)` | `search_string: str` (min 3 chars) | `list[dict]` | Searches transmission log programme details |

```python
results = tlpd.list_transmission_log_programme_details("news")
```

---

#### Buyers

>  A list of all buyer names available in the BARB API.

**API endpoint:** `GET /meta/buyers`

```python
from pybarb.metadata.buyers import Buyers

buyers_client = Buyers(conn)
```

| Method                                          | Returns        | Description                                |
|-------------------------------------------------|----------------|--------------------------------------------|
| `get_buyers()`                                  | `list[str]`    | Fetch all buyer names                      |
| `get_buyers_dataframe()`                        | `pd.DataFrame` | Fetch all buyers as a flat DataFrame       |

```python
# Get as a list of strings
buyers_list = buyers_client.get_buyers()
print(f"First 5 buyers: {buyers_list[:5]}")

# Get as a DataFrame
df = buyers_client.get_buyers_dataframe()
print(f"Total buyers: {len(df)}")
print(df.head(5).to_string(index=False))
```

---

#### Advertisers

>  A list of advertisers, their brands, and commercial numbers.

**API endpoint:** `GET /meta/advertisers`

```python
from pybarb.metadata.advertisers import Advertisers

advertisers_client = Advertisers(conn)
```

| Method                                              | Returns                | Description                                       |
|-----------------------------------------------------|------------------------|---------------------------------------------------|
| `get_advertisers(min_scheduled_date, ...)`           | `list[dict[str, Any]]` | Raw advertiser records                            |
| `get_advertisers_dataframe(min_scheduled_date, ...)` | `pd.DataFrame`         | Flat DataFrame with one row per commercial number |

**Parameters:**

| Parameter             | Type  | Required | Description                               |
|-----------------------|-------|----------|-------------------------------------------|
| `min_scheduled_date`   | `str` | No       | Start date in `YYYY-MM-DD` format         |
| `max_scheduled_date`   | `str` | No       | End date in `YYYY-MM-DD` format           |

```python
# Note: Both min_scheduled_date and max_scheduled_date are optional. 
# If not provided, it fetches all available advertisers.

# Get as a raw list of dictionaries
adv_data = advertisers_client.get_advertisers(
    min_scheduled_date="2026-08-01",
    max_scheduled_date="2026-08-31"
)

# You can also call it without parameters
adv_data_all = advertisers_client.get_advertisers()

# Get as a DataFrame (flattened to one row per commercial number)
df = advertisers_client.get_advertisers_dataframe(
    min_scheduled_date="2026-08-01",
    max_scheduled_date="2026-08-31"
)
print(f"Total commercial numbers: {len(df)}")
print(df.head(5).to_string(index=False))
```

---


### Metrics Endpoints

**Folder:** `pybarb/metrics/` — Use modules in this folder to access Metrics APIs.

>  "Metrics" means **the actual audience numbers** — how many people
> watched a programme, a commercial spot, or a channel during a given time period.
> This is the core data BARB is known for.
>
> All three metrics endpoints return data in the same way:
> - `get_*()` → raw data dictionary (for advanced use)
> - `get_*_flat_dataframe()` → first page as a ready-to-use table
> - `get_all_*_flat_dataframe()` → **all pages combined** into one table

---

#### Station Audiences

**File to import:** `pybarb.metrics.station.station_audiences`

**Pagination Note:** The BARB API returns data in chunks (pages). The `get_all_*_flat_dataframe()` method handles pagination automatically by fetching all pages and returning them combined into one table. Alternatively, for manual pagination, use the `x_next` token returned in the raw JSON response to fetch the next set of records via the `get_*_next_page(x_next=...)` method.

>  How many people watched a **specific channel** during each time slot on a given day.
> You need to specify the channel (`station_code`), the panel (`panel_code`),
> and the time slot length (e.g. `15` minutes).

**API endpoint:** `GET /metrics/station/audiences`

```python
from pybarb.metrics.station.station_audiences import StationAudiences

sa = StationAudiences(conn)
```

| Method                                      | Returns          | Description                                                                    |
|---------------------------------------------|------------------|--------------------------------------------------------------------------------|
| `get_station_audiences(...)`                | `dict[str, Any]` | Raw JSON payload containing the `stations_audiences` list                      |
| `get_station_audiences_flat_dataframe(...)` | `pd.DataFrame`   | Flattened DataFrame (`audience_views` list expanded into rows per time period) |
| `get_all_station_audiences_flat_dataframe(...)` | `pd.DataFrame`   | Automatically paginates and returns all records combined |

**Parameters:**

| Parameter                   | Type          | Required | Default | Description                        |
|-----------------------------|---------------|----------|---------|------------------------------------|
| `min_transmission_date`     | `str`         | Yes      | —       | Start date in `YYYY-MM-DD` format  |
| `max_transmission_date`     | `str`         | Yes      | —       | End date in `YYYY-MM-DD` format    |
| `station_code`              | `int \| str`  | Yes      | —       | Station code                       |
| `panel_code`                | `int \| str`  | Yes      | —       | Panel code                         |
| `time_period_length`        | `int \| str`  | Yes      | —       | Time period length in minutes      |
| `viewing_status`            | `str`         | Yes      | —       | e.g. `"VOSDAL"`, `"CONSOLIDATED"` |
| `use_polling_days`          | `bool \| str` | No       | `True`  | Use polling days                   |
| `x_next`                    | `str`         | No       | `None`  | Pagination token for next page     |
| `limit`                     | `int \| str`  | No       | `500`   | Page size limit                    |
| `last_updated_greater_than` | `str`         | No       | `None`  | ISO datetime filter                |

```python
df = sa.get_station_audiences_flat_dataframe(
    min_transmission_date="2023-07-20",
    max_transmission_date="2023-07-20",
    station_code=4934,
    panel_code=50,
    time_period_length=15,
    viewing_status="VOSDAL",
    limit=500,
)
print(df.head(5).to_string(index=False))
```

---

#### Programme Ratings

**File to import:** `pybarb.metrics.programme.programme_ratings`

**Pagination Note:** The BARB API returns data in chunks (pages). The `get_all_*_flat_dataframe()` method handles pagination automatically by fetching all pages and returning them combined into one table. Alternatively, for manual pagination, use the `x_next` token returned in the raw JSON response to fetch the next set of records via the `get_*_next_page(x_next=...)` method.

>  How many people watched each **individual programme** on a given day.
> Results include audience size (in hundreds) and TVR (Television Viewing Rating —
> the percentage of the panel that watched).

**API endpoint:** `GET /metrics/programme/ratings`

```python
from pybarb.metrics.programme.programme_ratings import ProgrammeRatings

pr = ProgrammeRatings(conn)
```

| Method                                        | Returns          | Description                                                                               |
|-----------------------------------------------|------------------|-------------------------------------------------------------------------------------------|
| `get_programme_ratings(...)`                | `dict[str, Any]` | Raw JSON payload containing the `programme_ratings` list                                |
| `get_programme_ratings_flat_dataframe(...)` | `pd.DataFrame`   | Flattened DataFrame (`audience_views` expanded into rows). Empty DataFrame if no results. |
| `get_all_programme_ratings_flat_dataframe(...)` | `pd.DataFrame`   | Automatically paginates and returns all records combined |

**Parameters:**

| Parameter                   | Type          | Required | Default | Description                        |
|-----------------------------|---------------|----------|---------|---------------------------------------|
| `min_transmission_date`     | `str`         | Yes      | —       | Start date in `YYYY-MM-DD` format  |
| `max_transmission_date`     | `str`         | Yes      | —       | End date in `YYYY-MM-DD` format    |
| `panel_code`                | `int \| str`  | Yes      | —       | Panel code                         |
| `consolidated`              | `bool \| str` | No       | `False` | Whether to use consolidated data   |
| `x_next`                    | `str`         | No       | `None`  | Pagination token for next page     |
| `limit`                     | `int \| str`  | No       | `500`   | Page size limit                    |
| `last_updated_greater_than` | `str`         | No       | `None`  | ISO datetime filter                |

```python
# Raw JSON
data = pr.get_programme_ratings(
    min_transmission_date="2026-05-06",
    max_transmission_date="2026-05-06",
    panel_code=50,
    consolidated=False,
    limit=500,
)

# Flat DataFrame
df = pr.get_programme_ratings_flat_dataframe(
    min_transmission_date="2026-05-06",
    max_transmission_date="2026-05-06",
    panel_code=50,
    consolidated=False,
    limit=500,
)

if not df.empty:
    print(df.head(5).to_string(index=False))
else:
    print("No data returned for this date range.")
```

---

#### Spot Impact

**File to import:** `pybarb.metrics.spot_impact`

**Pagination Note:** The BARB API returns data in chunks (pages). The `get_all_*_flat_dataframe()` method handles pagination automatically by fetching all pages and returning them combined into one table. Alternatively, for manual pagination, use the `x_next` token returned in the raw JSON response to fetch the next set of records via the `get_*_next_page(x_next=...)` method.

>  How many people saw each **individual advertisement** (spot) during a commercial break.
> This is used to measure advertising campaign effectiveness.

**API endpoint:** `GET /metrics/spot/impacts`

```python
from pybarb.metrics.spot_impact import SpotImpact

si = SpotImpact(conn)
```

| Method                                | Returns                | Description                                                    |
|---------------------------------------|------------------------|----------------------------------------------------------------|
| `get_spot_impact(...)`                | `list[dict[str, Any]]` | Raw list of spot impact event records                          |
| `get_spot_impact_flat_dataframe(...)` | `pd.DataFrame`         | Flattened DataFrame (`audience_views` list expanded into rows) |
| `get_all_spot_impact_flat_dataframe(...)` | `pd.DataFrame`   | Automatically paginates and returns all records combined |

**Parameters:**

| Parameter                     | Type          | Required | Default | Description                                 |
|-------------------------------|---------------|----------|---------|---------------------------------------------|
| `min_transmission_date`       | `str`         | Yes      | —       | Start date in `YYYY-MM-DD` format           |
| `max_transmission_date`       | `str`         | Yes      | —       | End date in `YYYY-MM-DD` format             |
| `station_code`                | `str`         | No       | `None`  | Filter by station code (comma separated)    |
| `panel_code`                  | `str`         | No       | `None`  | Filter by panel code (comma separated)      |
| `advertiser_name`             | `str`         | No       | `None`  | Filter by advertiser name                   |
| `buyer_name`                  | `str`         | No       | `None`  | Filter by buyer name                        |
| `consolidated`                | `bool`        | No       | `True`  | Whether to use consolidated data            |
| `standardise_audiences`       | `str` \| `bool`| No       | `None`  | Standardise audiences (e.g. using_duration) |
| `use_reporting_days`          | `str` \| `bool`| No       | `True`  | Use reporting days instead of standard      |
| `deduplicate_spots`           | `bool`        | No       | `True`  | Deduplicate matching Spot Impact rows; set to `False` to retain every row |
| `last_updated_greater_than`   | `str`         | No       | `None`  | ISO datetime filter for incremental loads   |
| `limit`                       | `int`         | No       | `None`  | Page size limit                             |
| `is_staggercast_station_code` | `bool`        | No       | `None`  | Filter staggercast stations                 |
| `x_next`                      | `str`         | No       | `None`  | Pagination token for next page (manual)     |

**Deduplication behaviour:**

`deduplicate_spots` defaults to `True`. Rows are grouped by
`station.station_name` and `spot_start_datetime.standard_datetime`. Within each
matching group, Pybarb keeps the first Online Multiple Screen Network row when
present. Otherwise, it keeps the first non-macro row, falling back to the first
row in API order. To identify duplicates across pages, Pybarb collects the 
complete paginated response before applying deduplication. Large result sets may
therefore require more memory and take longer to return.

```python
import gc
import json
from rich import print_json
from pybarb.connection.connection import Connection
from pybarb.utils.logging_config import get_logger

logger = get_logger(__name__)

def _section(title: str) -> None:
    """Print a clearly visible section banner."""
    print(f"\n{'=' * 60}\n  {title}\n{'=' * 60}")

def demo_spot_impact(conn: Connection) -> None:
    """Demo: Spot Impact metrics endpoint."""
    _section("SPOT IMPACT")
    from pybarb.metrics.spot_impact import SpotImpact

    client = SpotImpact(conn)

    # ── Raw JSON ──────────────────────────────────────────────────────────────
    data = client.get_spot_impact(
        min_transmission_date='2026-05-10',
        max_transmission_date='2026-05-10',
        # station_code=30,
        deduplicate_spots=False,   # True by default; set False to retrieve raw API duplicates
        limit=9999,
        advertiser_name='POSTCODE LOTTERY'
        # is_staggercast_station_code='false',
    )
    print_json(data=data)

    # ── DataFrame (With Deduplication) ────────────────────────────────────────
    df_dedup = client.get_spot_impact_flat_dataframe(
        min_transmission_date='2026-05-10',
        max_transmission_date='2026-05-10',
        # station_code=30,
        deduplicate_spots=True,    # Automatically clean duplicates
        limit=9999,
        advertiser_name='POSTCODE LOTTERY'
        # is_staggercast_station_code='false',
    )
    
    # ── DataFrame (Raw with Duplicates) ───────────────────────────────────────
    df_raw = client.get_spot_impact_flat_dataframe(
        min_transmission_date='2026-05-10',
        max_transmission_date='2026-05-10',
        # station_code=30,
        deduplicate_spots=False,   # Fetch raw data including API duplicates
        limit=9999,
        advertiser_name='POSTCODE LOTTERY'
        # is_staggercast_station_code='false',
    )
    
    if not df_dedup.empty and not df_raw.empty:
        print(f"\nRows after deduplication: {len(df_dedup)}")
        print(f"Raw rows (including duplicates): {len(df_raw)}")
        print(f"Duplicate records removed: {len(df_raw) - len(df_dedup)}")
        
        print("\nFirst 5 rows (deduplicated data):")
        print(df_dedup.head(5).to_string(index=False))
    else:
        print("No data available.")

    # ── Explicit cleanup ──────────────────────────────────────────────────────
    del data, df_dedup, df_raw, client
    gc.collect()

def main() -> None:
    try:
        # ── Establish Connection ──────────────────────────────────────────────────
        conn = Connection()
        conn.connect()
        # ── Execute Demo ──────────────────────────────────────────────────────────
        demo_spot_impact(conn)

    except FileNotFoundError as e:
        logger.error("Error: %s", e)
    except json.JSONDecodeError:
        logger.error("Error: Failed to decode creds.json. Ensure it's valid JSON.")
    except Exception:
        logger.exception("Unexpected error occurred")


if __name__ == '__main__':
    main()
```

---



#### Programme Reach

**File to import:** `pybarb.metrics.programme.programme_reach`

**Pagination Note:** The BARB API returns data in chunks (pages). The `get_all_*_flat_dataframe()` method handles pagination automatically by fetching all pages and returning them combined into one table. Alternatively, for manual pagination, use the `x_next` token returned in the raw JSON response to fetch the next set of records via the `get_*_next_page(x_next=...)` method.

>  **Theory (Programme Reach):** Reach measures the number of unique individuals within a specific target audience who watched at least a minimum consecutive duration of a given programme.
>  Unlike total impacts (which count every viewing instance, including repeat viewings by the same person), **reach** eliminates duplication to tell you exactly how many unique people were exposed to the programme. It is a critical metric for understanding a programme's absolute footprint and overall audience penetration across different demographic groups.

**API endpoint:** `POST /metrics/programme/reach-frequency/calculate`

```python
from pybarb.metrics.programme.programme_reach import ProgrammeReach

pr = ProgrammeReach(conn)
```

| Method                                      | Returns          | Description                                                                    |
|---------------------------------------------|------------------|--------------------------------------------------------------------------------|
| `get_programme_reach(...)`                  | `dict[str, Any]` | Raw JSON payload containing the `results` list                                 |
| `get_programme_reach_flat_dataframe(...)`   | `pd.DataFrame`   | Flattened DataFrame. Empty DataFrame if no results.                            |

**Parameters:**

| Parameter                   | Type          | Required | Default | Description                        |
|-----------------------------|---------------|----------|---------|------------------------------------|
| `programme_ids`             | `list[str]`   | Yes      | —       | List of programme IDs              |
| `audience_names`            | `list[str]`   | Yes      | —       | List of audience names             |
| `start_date`                | `str`         | Yes      | —       | Start date in `YYYY-MM-DD` format  |
| `end_date`                  | `str`         | Yes      | —       | End date in `YYYY-MM-DD` format    |

```python
import gc
import json
from rich import print_json
from pybarb.connection.connection import Connection
from pybarb.metrics.programme.programme_reach import ProgrammeReach
from pybarb.utils.logging_config import get_logger

logger = get_logger(__name__)

def demo_programme_reach(conn: Connection) -> None:
    """Demo: Programme Reach metrics endpoint."""
    print(f"\n{'=' * 60}\n  PROGRAMME REACH\n{'=' * 60}")
    client = ProgrammeReach(conn)

    # ── Raw JSON ──────────────────────────────────────────────────────────────
    data = client.get_programme_reach(
        programme_ids=['BBC News_swmdnz'],
        audience_names=['All Women'],
        start_date='2026-01-01',
        end_date='2026-01-20',
    )
    print("--- Programme Reach JSON ---")
    print_json(data=data)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    df = client.get_programme_reach_flat_dataframe(
        programme_ids=['BBC News_swmdnz'],
        audience_names=['All Women'],
        start_date='2026-01-01',
        end_date='2026-01-20',
    )
    print(f"Shape: {df.shape}")
    if not df.empty:
        print(df.head(5).to_string(index=False))   # FIX: head(5) only — was full df
    else:
        print("No data available.")

    # ── Explicit cleanup ──────────────────────────────────────────────────────
    del data, df, client
    gc.collect()

def main() -> None:
    try:
        # ── Establish Connection ──────────────────────────────────────────────────
        conn = Connection()
        conn.connect()
        # ── Execute Demo ──────────────────────────────────────────────────────────
        demo_programme_reach(conn)

    except FileNotFoundError as e:
        logger.error("Error: %s", e)
    except json.JSONDecodeError:
        logger.error("Error: Failed to decode creds.json. Ensure it's valid JSON.")
    except Exception:
        logger.exception("Unexpected error occurred")


if __name__ == '__main__':
    main()
```

**Sample Output:**

```text
============================================================
  PROGRAMME REACH
============================================================
--- Programme Reach JSON ---
{
  "programme_ids": ["BBC News_swmdnz"],
  "start_date": "2026-01-01",
  "end_date": "2026-01-20",
  "calculation_version": "test-reach-frequency-v1.0.0",
  "results": [
    {
      "audience_name": "All Women",
      "reach_pct": 0.05,
      "reach_count": 100,
      "average_frequency": 1.0,
      "total_impacts": 100
    }
  ]
}
Shape: (1, 8)
audience_name  reach_pct  reach_count  average_frequency  total_impacts start_date   end_date calculation_version
    All Women       0.05          100                1.0            100 2026-01-01 2026-01-20 test-reach-frequency-v1.0.0
```

---

#### Spot Reach

**File to import:** `pybarb.metrics.spot.spot_reach`

**Pagination Note:** The BARB API returns data in chunks (pages). The `get_all_*_flat_dataframe()` method handles pagination automatically by fetching all pages and returning them combined into one table. Alternatively, for manual pagination, use the `x_next` token returned in the raw JSON response to fetch the next set of records via the `get_*_next_page(x_next=...)` method.

>  **Theory (Spot Reach):** Spot reach measures the number of unique individuals within a target audience who were exposed to a specific commercial advertisement (identified by its unique clock number).
>  In advertising, understanding spot reach is essential for calculating the true coverage of a campaign. While total impacts tell you how many times an ad was seen in total, **reach** tells you how many unique people actually saw it. This helps media buyers evaluate campaign effectiveness and manage frequency (how many times, on average, a reached individual saw the spot).

**API endpoint:** `POST /metrics/spot/reach-frequency/calculate`

```python
from pybarb.metrics.spot.spot_reach import SpotReach

sr = SpotReach(conn)
```

| Method                                      | Returns          | Description                                                                    |
|---------------------------------------------|------------------|--------------------------------------------------------------------------------|
| `get_spot_reach(...)`                       | `dict[str, Any]` | Raw JSON payload containing the `results` list                                 |
| `get_spot_reach_flat_dataframe(...)`        | `pd.DataFrame`   | Flattened DataFrame. Empty DataFrame if no results.                            |

**Parameters:**

| Parameter                   | Type          | Required | Default | Description                        |
|-----------------------------|---------------|----------|---------|------------------------------------|
| `clock_numbers`             | `list[str]`   | Yes      | —       | List of clock numbers              |
| `audience_names`            | `list[str]`   | Yes      | —       | List of audience names             |
| `start_date`                | `str`         | Yes      | —       | Start date in `YYYY-MM-DD` format  |
| `end_date`                  | `str`         | Yes      | —       | End date in `YYYY-MM-DD` format    |

```python
import gc
import json
from rich import print_json
from pybarb.connection.connection import Connection
from pybarb.metrics.spot.spot_reach import SpotReach
from pybarb.utils.logging_config import get_logger

logger = get_logger(__name__)

def demo_spot_reach(conn: Connection) -> None:
    """Demo: Spot Reach metrics endpoint."""
    print(f"\n{'=' * 60}\n  SPOT REACH\n{'=' * 60}")
    client = SpotReach(conn)

    # ── Raw JSON ──────────────────────────────────────────────────────────────
    data = client.get_spot_reach(
        clock_numbers=['CCELIVE001030'],
        audience_names=['All Individuals'],
        start_date='2026-01-01',
        end_date='2026-01-01',
    )
    print("--- Spot Reach JSON ---")
    print_json(data=data)

    # ── DataFrame ─────────────────────────────────────────────────────────────
    df = client.get_spot_reach_flat_dataframe(
        clock_numbers=['CCELIVE001030'],
        audience_names=['All Individuals'],
        start_date='2026-01-01',
        end_date='2026-01-01',
    )
    print(f"Shape: {df.shape}")
    if not df.empty:
        print(df.head(5).to_string(index=False))   # FIX: head(5) only — was full df
    else:
        print("No data available.")

    # ── Explicit cleanup ──────────────────────────────────────────────────────
    del data, df, client
    gc.collect()

def main() -> None:
    try:
        # ── Establish Connection ──────────────────────────────────────────────────
        conn = Connection()
        conn.connect()

        # ── Execute Demo ──────────────────────────────────────────────────────────
        demo_spot_reach(conn)

    except FileNotFoundError as e:
        logger.error("Error: %s", e)
    except json.JSONDecodeError:
        logger.error("Error: Failed to decode creds.json. Ensure it's valid JSON.")
    except Exception:
        logger.exception("Unexpected error occurred")


if __name__ == '__main__':
    main()
```

**Sample Output:**

```text
============================================================
  SPOT REACH
============================================================
--- Spot Reach JSON ---
{
  "clock_numbers": ["CCELIVE001030"],
  "start_date": "2026-01-01",
  "end_date": "2026-01-01",
  "calculation_version": "test-version-v1",
  "results": [
    {
      "audience_name": "All Individuals",
      "reach_pct": 50.0,
      "reach_count": 1000,
      "average_frequency": 5.0,
      "total_impacts": 5000
    }
  ]
}
Shape: (1, 8)
  audience_name  reach_pct  reach_count  average_frequency  total_impacts start_date   end_date calculation_version
All Individuals       50.0         1000                5.0           5000 2026-01-01 2026-01-01     test-version-v1
```





---

### Bulk Endpoints

**Folder:** `pybarb/bulk/` — Use modules in this folder to access Bulk APIs.

> Bulk endpoints return **signed file URLs** linking to Parquet files instead of direct JSON data payloads. This is designed for downloading very large datasets.
>
> **Every bulk endpoint works the same way:**
> 1. Create a client object (e.g. `ProgrammeSchedule(conn)`)
> 2. Fetch the JSON response containing signed URLs using `get_*()` or as a flat DataFrame using `get_*_dataframe()`.
> 3. Download the actual Parquet files using `download_*_files()`. You can specify a custom `download_dir` or it will default to a `downloads/` directory in your current working directory.
> 4. Load the downloaded Parquet files into a single Pandas DataFrame using `load_parquet_files_to_dataframe(file_paths)`.
> 5. (Optional) Flatten any nested JSON data within the Parquet files using `flatten_*_dataframe(df)`.

#### Downloading and Loading Data

Unlike Metadata and Metrics endpoints, Bulk endpoints require you to download files before you can analyze the actual data. The API will return one or more signed URLs, or it might return an empty list if no data is available for the given filters.

**Example: Handling Multiple Files and No-Data Responses**

```python
from pathlib import Path
from pybarb.bulk.programme_schedule import ProgrammeSchedule
import pandas as pd

# 1. Create the client
bulk_ps = ProgrammeSchedule(conn)

try:
    # 2. Download the files (this handles the signed URLs automatically)
    # The default location is ./downloads/ if download_dir is not specified.
    downloaded_files = bulk_ps.download_programme_schedule_files(
        min_scheduled_date="2026-05-01",
        max_scheduled_date="2026-05-31",
        download_dir=Path("./my_custom_downloads")
    )
    
    # 3. Check if any data was returned
    if not downloaded_files:
        print("No files were returned for this date range.")
    else:
        print(f"Successfully downloaded {len(downloaded_files)} files.")
        
        # 4. Load all files into a single DataFrame
        df = bulk_ps.load_parquet_files_to_dataframe(downloaded_files)
        
        # 5. Flatten any nested structures
        flat_df = bulk_ps.flatten_programme_schedule_dataframe(df)
        print(flat_df.head())
        
except Exception as e:
    # Handle failed API calls or download failures
    print(f"An error occurred: {e}")
```

**Expected JSON Response (containing Signed URLs):**
```json
[
  {
    "scheduled_date": "2026-05-01",
    "results": [
      "https://barb-api-files.s3.eu-west-1.amazonaws.com/bulk/programme/schedules/2026/05/01/file1.parquet?X-Amz-Algorithm=...",
      "https://barb-api-files.s3.eu-west-1.amazonaws.com/bulk/programme/schedules/2026/05/01/file2.parquet?X-Amz-Algorithm=..."
    ]
  }
]
```

---

#### Programme Schedule Bulk

**File to import:** `pybarb.bulk.programme_schedule`

> Programme schedule bulk files containing details of broadcasted programmes.

**API endpoint:** `GET /api/v3/bulk/programme/schedules`

```python
from pathlib import Path
from pybarb.bulk.programme_schedule import ProgrammeSchedule

# 1. Initialize client
ps = ProgrammeSchedule(conn)

# 2. Download files
files = ps.download_programme_schedule_files(
    min_scheduled_date="2026-05-01",
    max_scheduled_date="2026-05-31",
    station_code="4934",
    download_dir=Path("./downloads/programme_schedule")
)

# 3. Load and flatten
if files:
    df = ps.load_parquet_files_to_dataframe(files)
    flat_df = ps.flatten_programme_schedule_dataframe(df)
    print(flat_df.head())
```

**Parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `min_scheduled_date` | `str` | Yes | Start date in `YYYY-MM-DD` format |
| `max_scheduled_date` | `str` | Yes | End date in `YYYY-MM-DD` format |
| `station_code` | `str` | No | Filter by station code |
| `last_updated_greater_than` | `str` | No | ISO datetime filter |

---

#### Spot Schedule Bulk

**File to import:** `pybarb.bulk.spot_schedule`

> Spot schedule bulk files containing details of commercial advertisement spots.

**API endpoint:** `GET /api/v3/bulk/spot/schedules`

```python
from pathlib import Path
from pybarb.bulk.spot_schedule import SpotSchedule

# 1. Initialize client
ss = SpotSchedule(conn)

# 2. Download files
files = ss.download_spot_schedule_files(
    min_scheduled_date="2026-05-01",
    max_scheduled_date="2026-05-31",
    station_code="30",
    download_dir=Path("./downloads/spot_schedule")
)

# 3. Load and flatten
if files:
    df = ss.load_parquet_files_to_dataframe(files)
    flat_df = ss.flatten_spot_schedule_dataframe(df)
    print(flat_df.head())
```

**Parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `min_scheduled_date` | `str` | Yes | Start date in `YYYY-MM-DD` format |
| `max_scheduled_date` | `str` | Yes | End date in `YYYY-MM-DD` format |
| `station_code` | `str` | No | Filter by station code |
| `last_updated_greater_than` | `str` | No | ISO datetime filter |

---

#### Programme Ratings Bulk

**File to import:** `pybarb.bulk.programme_ratings`

> Programme ratings bulk files containing audience size and TVR for programmes.

**API endpoint:** `GET /api/v3/bulk/programme/ratings`

```python
from pathlib import Path
from pybarb.bulk.programme_ratings import ProgrammeRatings

# 1. Initialize client
pr = ProgrammeRatings(conn)

# 2. Download files
files = pr.download_programme_ratings_files(
    min_transmission_date="2026-05-01",
    max_transmission_date="2026-05-31",
    download_dir=Path("./downloads/programme_ratings")
)

# 3. Load and flatten
if files:
    df = pr.load_parquet_files_to_dataframe(files)
    flat_df = pr.flatten_programme_ratings_dataframe(df)
    print(flat_df.head())
```

**Parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `min_transmission_date` | `str` | Yes | Start date in `YYYY-MM-DD` format |
| `max_transmission_date` | `str` | Yes | End date in `YYYY-MM-DD` format |
| `last_updated_greater_than` | `str` | No | ISO datetime filter |

---

#### Spot Impacts Bulk

**File to import:** `pybarb.bulk.spot_impacts`

> Spot impacts bulk files for analyzing advertisement effectiveness.

**API endpoint:** `GET /api/v3/bulk/spot/impacts`

```python
from pathlib import Path
from pybarb.bulk.spot_impacts import SpotImpacts

# 1. Initialize client
si = SpotImpacts(conn)

# 2. Download files
files = si.download_spot_impacts_files(
    min_transmission_date="2026-05-01",
    max_transmission_date="2026-05-31",
    download_dir=Path("./downloads/spot_impacts")
)

# 3. Load and flatten
if files:
    df = si.load_parquet_files_to_dataframe(files)
    flat_df = si.flatten_spot_impacts_dataframe(df)
    print(flat_df.head())
```

**Parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `min_transmission_date` | `str` | Yes | Start date in `YYYY-MM-DD` format |
| `max_transmission_date` | `str` | Yes | End date in `YYYY-MM-DD` format |
| `last_updated_greater_than` | `str` | No | ISO datetime filter |

---

#### Station Audience Bulk

**File to import:** `pybarb.bulk.station_audiences`

> Station audience bulk files detailing viewers per channel.

**API endpoint:** `GET /api/v3/bulk/stations/audience`

```python
from pathlib import Path
from pybarb.bulk.station_audiences import StationAudiences

# 1. Initialize client
sa = StationAudiences(conn)

# 2. Download files
files = sa.download_station_audiences_files(
    min_transmission_date="2026-05-01",
    max_transmission_date="2026-05-31",
    download_dir=Path("./downloads/station_audiences")
)

# 3. Load and flatten
if files:
    df = sa.load_parquet_files_to_dataframe(files)
    flat_df = sa.flatten_station_audiences_dataframe(df)
    print(flat_df.head())
```

**Parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `min_transmission_date` | `str` | Yes | Start date in `YYYY-MM-DD` format |
| `max_transmission_date` | `str` | Yes | End date in `YYYY-MM-DD` format |
| `last_updated_greater_than` | `str` | No | ISO datetime filter |

---

#### Programme Audience Bulk

**File to import:** `pybarb.bulk.programme_audience`

> Programme audience bulk files.

**API endpoint:** `GET /api/v3/bulk/programme/audiences`

```python
from pathlib import Path
from pybarb.bulk.programme_audience import ProgrammeAudience

# 1. Initialize client
pa = ProgrammeAudience(conn)

# 2. Download files
files = pa.download_programme_audience_files(
    min_session_date="2026-05-01",
    max_session_date="2026-05-31",
    panel_code="50",
    download_dir=Path("./downloads/programme_audience")
)

# 3. Load and flatten
if files:
    df = pa.load_parquet_files_to_dataframe(files)
    flat_df = pa.flatten_programme_audience_dataframe(df)
    print(flat_df.head())
```

**Parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `min_session_date` | `str` | Yes | Start date in `YYYY-MM-DD` format |
| `max_session_date` | `str` | Yes | End date in `YYYY-MM-DD` format |
| `panel_code` | `str` | No | Filter by panel code |
| `last_updated_greater_than` | `str` | No | ISO datetime filter |

---

#### Spot Audience Bulk

**File to import:** `pybarb.bulk.spot_audience`

> Spot audience bulk files.

**API endpoint:** `GET /api/v3/bulk/spot/audiences`

```python
from pathlib import Path
from pybarb.bulk.spot_audience import SpotAudience

# 1. Initialize client
saud = SpotAudience(conn)

# 2. Download files
files = saud.download_spot_audience_files(
    min_session_date="2026-05-01",
    max_session_date="2026-05-31",
    panel_code="50",
    download_dir=Path("./downloads/spot_audience")
)

# 3. Load and flatten
if files:
    df = saud.load_parquet_files_to_dataframe(files)
    flat_df = saud.flatten_spot_audience_dataframe(df)
    print(flat_df.head())
```

**Parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `min_session_date` | `str` | Yes | Start date in `YYYY-MM-DD` format |
| `max_session_date` | `str` | Yes | End date in `YYYY-MM-DD` format |
| `panel_code` | `str` | No | Filter by panel code |
| `last_updated_greater_than` | `str` | No | ISO datetime filter |

---

#### Viewing Bulk

**File to import:** `pybarb.bulk.viewing`

> Bulk viewing files detailing household viewing events.

**API endpoint:** `GET /api/v3/bulk/viewing`

```python
from pathlib import Path
from pybarb.bulk.viewing import Viewing

# 1. Initialize client
vw = Viewing(conn)

# 2. Download files
files = vw.download_viewing_files(
    min_session_date="2026-05-01",
    max_session_date="2026-05-31",
    panel_code="50",
    download_dir=Path("./downloads/viewing")
)

# 3. Load and flatten
if files:
    df = vw.load_parquet_files_to_dataframe(files)
    flat_df = vw.flatten_viewing_dataframe(df)
    print(flat_df.head())
```

**Parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `min_session_date` | `str` | Yes | Start date in `YYYY-MM-DD` format |
| `max_session_date` | `str` | Yes | End date in `YYYY-MM-DD` format |
| `panel_code` | `str` | No | Filter by panel code |
| `last_updated_greater_than` | `str` | No | ISO datetime filter |

---


## Common Use Cases

>  Below are ready-to-copy recipes for the most common things people
> do with this library. Find the one that matches what you want, paste it into your script,
> and swap the dates / channel names for your own values.

---

###  "I want to know how many people watched BBC1 on a specific day"

```python
from pybarb.connection.connection import Connection
from pybarb.metadata.station import Station
from pybarb.metrics.station.station_audiences import StationAudiences

conn = Connection()
conn.connect()

# Find BBC1's station code
station_code = Station(conn).get_station_code("BBC1")

# Fetch 15-minute slot audiences for a single day
sa = StationAudiences(conn)
df = sa.get_all_station_audiences_flat_dataframe(
    min_transmission_date="2023-07-20",
    max_transmission_date="2023-07-20",
    station_code=station_code,
    panel_code=50,          # 50 = UK Total panel
    time_period_length=15,  # 15-minute slots
    viewing_status="VOSDAL",
)
print(f"Total time slots: {len(df)}")
print(df[["time_period", "audience_size_hundreds", "tvr"]].head(10).to_string(index=False))
```

---

###  "I want programme-by-programme audience figures for a channel"

```python
from pybarb.connection.connection import Connection
from pybarb.metrics.programme.programme_ratings import ProgrammeRatings

conn = Connection()
conn.connect()

pr = ProgrammeRatings(conn)
df = pr.get_all_programme_ratings_flat_dataframe(
    min_transmission_date="2023-07-20",
    max_transmission_date="2023-07-20",
    panel_code=50,
    consolidated=False,   # True = includes catch-up viewing within 28 days
    limit=500,
)

if df.empty:
    print("No data found for this date range.")
else:
    # Show top programmes by audience size
    top = df.sort_values("audience_size_hundreds", ascending=False).head(10)
    print(top[["programme_name", "transmission_date", "audience_size_hundreds", "tvr"]].to_string(index=False))
```

---

###  "I want a list of all TV channels available in BARB"

```python
from pybarb.connection.connection import Connection
from pybarb.metadata.station import Station

conn = Connection()
conn.connect()

# All channels
all_channels = Station(conn).list_stations()
print(f"Total channels: {len(all_channels)}")
print("\n".join(all_channels[:20]))   # first 20

# BBC channels only
bbc_channels = Station(conn).list_stations(regex_filter="^BBC")
print("\nBBC channels:", bbc_channels)
```

---

###  "I want to see what programmes aired on a channel on a given day"

```python
from pybarb.connection.connection import Connection
from pybarb.metadata.station import Station
from pybarb.metadata.programme_schedule import ProgrammeSchedule

conn = Connection()
conn.connect()

station_code = Station(conn).get_station_code("ITV1")

ps = ProgrammeSchedule(conn)
df = ps.get_programme_schedule_flat_dataframe(
    min_schedule_date="2024-01-15",
    max_schedule_date="2024-01-15",
    station_code=str(station_code),
)
print(df[["programme_name", "start_time", "duration_minutes"]].to_string(index=False))
```

---

###  "I want to save audience data to a CSV file"

```python
from pybarb.connection.connection import Connection
from pybarb.metadata.station import Station
from pybarb.metrics.station.station_audiences import StationAudiences

conn = Connection()
conn.connect()

station_code = Station(conn).get_station_code("Channel 4")
sa = StationAudiences(conn)

df = sa.get_all_station_audiences_flat_dataframe(
    min_transmission_date="2023-07-20",
    max_transmission_date="2023-07-20",
    station_code=station_code,
    panel_code=50,
    time_period_length=30,
    viewing_status="VOSDAL",
)

# Save to CSV — open in Excel or any spreadsheet tool
df.to_csv("channel4_audiences_2023-07-20.csv", index=False)
print(f"Saved {len(df)} rows to CSV.")
```

---

###  "I want to see the audience for a specific TV advertisement (spot)"

```python
from pybarb.connection.connection import Connection
from pybarb.metrics.spot_impact import SpotImpact

conn = Connection()
conn.connect()

si = SpotImpact(conn)
df = si.get_all_spot_impact_flat_dataframe(
    min_transmission_date="2026-05-01",
    max_transmission_date="2026-05-31",
    use_reporting_days=False,
    advertiser_name="32RED",
    consolidated=True
)

if not df.empty:
    print(f"Total spots: {len(df)}")
    print(df.head(5).to_string(index=False))
else:
    print("No spot data found.")
```

---

###  "I want to see the reach for specific programmes"

```python
from pybarb.connection.connection import Connection
from pybarb.metrics.programme.programme_reach import ProgrammeReach

conn = Connection()
conn.connect()

pr = ProgrammeReach(conn)
df = pr.get_all_programme_reach_flat_dataframe(
    programme_ids="1234,5678",           # Replace with your target programme IDs
    audience_names="Adults,Children",    # Replace with your target audience names
    start_date="2026-05-01",
    end_date="2026-05-07"
)

if not df.empty:
    print(f"Total reach records: {len(df)}")
    print(df.head(5).to_string(index=False))
else:
    print("No reach data found for these programmes.")
```

---

###  "I want to see the reach for specific TV advertisement spots"

```python
from pybarb.connection.connection import Connection
from pybarb.metrics.spot.spot_reach import SpotReach

conn = Connection()
conn.connect()

sr = SpotReach(conn)
df = sr.get_all_spot_reach_flat_dataframe(
    clock_numbers="ABC1234,XYZ9876",     # Replace with your target clock numbers
    audience_names="Adults,Children",    # Replace with your target audience names
    start_date="2026-05-01",
    end_date="2026-05-07"
)

if not df.empty:
    print(f"Total spot reach records: {len(df)}")
    print(df.head(5).to_string(index=False))
else:
    print("No spot reach data found.")
```

---

###  "I want to fetch data for a large date range without running out of memory"

For very large date ranges, use the manual pagination approach to process one page at a time:

```python
from pybarb.connection.connection import Connection
from pybarb.metrics.programme.programme_ratings import ProgrammeRatings
import pandas as pd

conn = Connection()
conn.connect()

pr = ProgrammeRatings(conn)
all_pages = []

result = pr.get_programme_ratings(
    min_transmission_date="2023-01-01",
    max_transmission_date="2023-12-31",
    panel_code=50,
    limit=500,
)
all_pages.append(pd.DataFrame(result["programme_ratings"]))
x_next = result["x_next"]

page_num = 1
while x_next:
    result = pr.get_programme_ratings_next_page(
        x_next=x_next,
        min_transmission_date="2023-01-01",
        max_transmission_date="2023-12-31",
        panel_code=50,
        limit=500,
    )
    page_num += 1
    page_df = pd.DataFrame(result["programme_ratings"])
    page_df.to_csv(f"page_{page_num:04d}.csv", index=False)  # save each page separately
    print(f"Page {page_num}: {len(page_df)} records saved.")
    x_next = result["x_next"]

print(f"Done. {page_num} pages fetched.")
```

---

## Troubleshooting

>  **Something not working?** Check the table below for the most common problems and
> how to fix them. If your error isn't listed here, look at the full [Error Reference](#error-reference)
> section or check the error message — it usually tells you exactly what went wrong.

---

### `ValueError: access_token must not be blank.`

**What it means:** You called `connect_with_tokens()` without providing a valid access token.  
**How to fix:**
- Make sure you are passing a non-empty string to `access_token=`.
- Verify you have obtained a valid token from BARB before calling `connect_with_tokens()`.

---

###  `RuntimeError: Unauthorized: invalid API credentials (status 401).`

**What it means:** The access or refresh token is invalid or has expired.
**How to fix:**
- If the connection already holds a refresh token, call `conn.ensure_token_valid()`.
- Otherwise, obtain a new refresh token and call `connect()` or
  `connect_with_refresh_token(...)`.
- Contact BARB to confirm your account is still active.

---

###  `ApiError: No <resource> returned.`

**What it means:** The query returned zero results — not an error in your code.  
**How to fix:**
- Check that your date range contains data (BARB data may have a delay of 1–2 days).
- Check your `station_code` and `panel_code` are correct.
- Try a shorter date range (start with a single day: `min_date = max_date`).

---

### `ApiError: Connection headers not set. Authenticate the connection first.`

**What it means:** You tried to fetch data before authenticating.  
**How to fix:** Call `conn.connect()` before creating any data client objects.
Use one of the lower-level connection methods only when the caller supplies the
tokens directly.

```python
conn = Connection()
conn.connect()  # Must be called before anything else
sa = StationAudiences(conn)
```

---

###  `ModuleNotFoundError: No module named 'pybarb'`

**What it means:** The library is not installed, or you are using a different Python environment.  
**How to fix:**
- Run `pip install pybarb` again in the same terminal you use to run your script.
- If you use a virtual environment (`.venv`), make sure it is activated before installing and running.
- Check `python --version` matches the Python you used to install the library.

---

###  `ApiError: Too many requests to Barb API (status 429).`

**What it means:** You have made too many API requests in a short period.  
**How to fix:** The SDK automatically retries with exponential backoff (up to 3 retries). If this
error is still raised after retries, wait a few minutes before running your script again. Avoid
running the same large query multiple times in parallel.

---

###  I am connecting to the wrong BARB environment (UAT vs Production)

**Symptoms:** Your data appears to be coming from the wrong environment.  
**How to fix:**
- `BARB_API_ROOT` takes precedence over the constructor argument. Update or remove
  that environment variable first.
- When `BARB_API_ROOT` is unset, pass the API root explicitly:

```python
from pybarb.connection.connection import Connection

conn = Connection(api_root="https://api.barb.co.uk/api/v3/")
```

- Then authenticate using `connect()`, `connect_with_tokens(...)`, or
  `connect_with_refresh_token(...)`.

If you are using a `.env` file for other settings (like logging), it must be in the **current working directory** when you run your script.
You can check which directory Python is looking in with:

```python
import os
print(os.getcwd())   # Should be the folder containing your .env file
```

---

###  I get a large number of rows and the script is very slow

**How to fix:**
- Narrow your date range — start with a single day.
- Lower the `limit` parameter (e.g. `limit=100`) to fetch fewer records per page.
- Save results to CSV after fetching so you don't need to re-fetch on the next run.

---

## Error Reference

>  When something goes wrong, the library raises an **error** (exception)
> with a clear message explaining what happened. The two types of errors are:
> - **`ApiError`** — the request was understood but failed (e.g. wrong date format, no data found)
> - **`RuntimeError`** — the connection itself failed (e.g. token expired, network unreachable)

All client errors raised are instances of `ApiError` (see [Exception Classes](#exception-classes)).
Low-level connection failures raise `RuntimeError`.

---

### Connection Errors

Invalid method arguments raise `ValueError`. Network, HTTP and token-response
failures raise `RuntimeError`.

#### `connect_with_tokens()` errors

| Raised by | Condition | Exception and message |
|-----------|-----------|-----------------------|
| `connect_with_tokens()` | `access_token` is blank | `ValueError: access_token must not be blank.` |
| `connect_with_tokens()` | `refresh_token` is blank | `ValueError: refresh_token must not be blank.` |
| `connect_with_tokens()` | Expiry value is invalid | `ValueError` describing the invalid expiry |
| `connect_with_refresh_token()` | `refresh_token` is blank | `ValueError: refresh_token must not be blank.` |

#### Token refresh errors (`ensure_token_valid` / `_do_token_refresh`)

| Raised by              | Condition                             | Message                                                                                                                   |
|------------------------|---------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
| `ensure_token_valid()` | Valid access token unavailable and no refresh token | `A valid access token is unavailable and no refresh token was provided.` |
| `_do_token_refresh()`  | Network error during refresh          | `Unable to reach Barb API. Check your internet connection.`                                                               |
| `_do_token_refresh()`  | Timeout during refresh                | `Request to Barb API timed out. Please try again.`                                                                        |
| `_do_token_refresh()`  | Non-200 response from token endpoint  | HTTP status error (see [HTTP Status Errors](#http-status-errors))                                                         |
| `_do_token_refresh()`  | Missing `access_token` in response    | `Authentication response did not include an access token.`                                                                |
| `_do_token_refresh()`  | Invalid JSON, refresh token or `expires_in` | `Invalid response from Barb API.` |

---

### HTTP Status Errors

>  These are standard web error codes. The most common ones you might see:
> - **401** — your credentials are wrong or expired
> - **429** — you're making too many requests (SDK retries automatically)
> - **404** — the endpoint URL is wrong

Returned when the BARB API responds with a non-200 HTTP status code.  
Format: `"<message> (status <code>)."`.

| Status Code | Message                                   |
|-------------|-------------------------------------------|
| `400`       | `Bad request to Barb API`                 |
| `401`       | `Unauthorized: invalid API credentials`   |
| `403`       | `Forbidden: access denied by Barb API`    |
| `404`       | `Barb API endpoint not found`             |
| `429`       | `Too many requests to Barb API`           |
| `500`       | `Barb API internal server error`          |
| `502`       | `Barb API gateway error`                  |
| `503`       | `Barb API is temporarily unavailable`     |
| `504`       | `Barb API gateway timeout`                |
| _other_     | `Barb API request failed`                 |

---

### Metadata Endpoint Errors

All raised as `ApiError`.

#### General (shared across all metadata endpoints)

| Error Key          | Message                                                       | Cause                                              |
|--------------------|---------------------------------------------------------------|----------------------------------------------------|
| `headers_missing`  | `Connection headers not set. Authenticate the connection first.` | No authentication method was called before making requests |
| `network_error`    | `Unable to fetch <resource>. Check your internet connection.` | Network exception during the API call              |
| `malformed_json`   | `Invalid <resource> response from Barb API.`                  | Response body was not valid JSON                   |
| `payload_not_list` | `Unexpected <resource> payload type: <type>`                  | Response parsed but was not a list                 |
| `no_results`       | `No <resource> returned.`                                     | API returned an empty list                         |

#### Station-specific

| Error Key                  | Message                                    | Cause                                       |
|----------------------------|--------------------------------------------|---------------------------------------------|
| `invalid_station_name`     | `station_name must be a non-empty string.` | Blank or non-string station name passed     |
| `station_not_found`        | `Station name '{name}' not found.`         | No station matched the given name           |
| `station_multiple_matches` | `Multiple stations matched name '{name}'.` | More than one exact match found             |

#### Panel-specific

| Error Key                | Message                                      | Cause                       |
|--------------------------|----------------------------------------------|-----------------------------|
| `panel_region_required`  | `panel_region must be a non-empty string.`   | Blank or non-string region  |
| `panel_not_found`        | `Panel region '{region}' not found.`         | No panel matched the region |
| `panel_multiple_matches` | `Multiple panels matched region '{region}'.` | More than one exact match   |
| `invalid_regex`          | `Invalid regex pattern for panel_region.`    | Regex compilation failed    |

#### Households / Panel Members

| Error Key                   | Message                         | Cause                               |
|-----------------------------|---------------------------------|-------------------------------------|
| `panel_start_date_required` | `panel_start_date is required.` | `panel_start_date` is blank/missing |
| `panel_end_date_required`   | `panel_end_date is required.`   | `panel_end_date` is blank/missing   |

#### Spot Schedule

| Error Key                     | Message                           | Cause                                 |
|-------------------------------|-----------------------------------|---------------------------------------|
| `min_scheduled_date_required` | `min_scheduled_date is required.` | `min_scheduled_date` is blank/missing |
| `max_scheduled_date_required` | `max_scheduled_date is required.` | `max_scheduled_date` is blank/missing |

#### Programme Schedule

| Error Key                    | Message                          | Cause                                |
|------------------------------|----------------------------------|--------------------------------------|
| `min_schedule_date_required` | `min_schedule_date is required.` | `min_schedule_date` is blank/missing |
| `max_schedule_date_required` | `max_schedule_date is required.` | `max_schedule_date` is blank/missing |

#### Target Audience Categories

| Error Key             | Message                                                      | Cause                             |
|-----------------------|--------------------------------------------------------------|-----------------------------------|
| `max_date_required`   | `max_date is required.`                                      | `max_date` is blank/missing       |
| `min_date_required`   | `min_date is required.`                                      | `min_date` is blank/missing       |
| `panel_code_required` | `panel_code is required.`                                    | `panel_code` is `None`            |
| `panel_code_limit`    | `panel_code accepts a maximum of 10 comma separated values.` | More than 10 panel codes provided |

#### Search Endpoints (Programme Content / Transmission Log)

| Error Key                  | Message                                             | Cause                                  |
|----------------------------|-----------------------------------------------------|----------------------------------------|
| `search_string_required`   | `search_string may not be blank.`                   | Empty or whitespace-only search string |
| `search_string_min_length` | `search_string must be at least 3 characters long.` | Search string shorter than 3 chars     |

---

### Metrics Endpoint Errors

Applies to `StationAudiences`, `ProgrammeRatings`, and `SpotImpact`.

| Error Key                        | Message                                                       | Cause                                    |
|----------------------------------|---------------------------------------------------------------|------------------------------------------|
| `headers_missing`                | `Connection headers not set. Authenticate the connection first.` | No authentication method was called    |
| `min_transmission_date_required` | `min_transmission_date is required.`                          | `min_transmission_date` is blank/missing |
| `max_transmission_date_required` | `max_transmission_date is required.`                          | `max_transmission_date` is blank/missing |
| `station_code_required`          | `station_code is required.`                                   | `station_code` is `None` or empty        |
| `panel_code_required`            | `panel_code is required.`                                     | `panel_code` is `None` or empty          |
| `network_error`                  | `Unable to fetch <resource>. Check your internet connection.` | Network exception during the API call    |
| `malformed_json`                 | `Invalid <resource> response from Barb API.`                  | Response body was not valid JSON         |
| `payload_not_dict`               | `Unexpected <resource> payload type: <type>`                  | Response was not a dict                  |
| `no_results`                     | `No <resource> returned.`                                     | API returned no records                  |

---

## Exception Classes

### `ApiError`

```python
from pybarb.utils import ApiError
```

Custom exception raised for all BARB API business-logic and validation failures.

| Attribute       | Type          | Description                                   |
|-----------------|---------------|-----------------------------------------------|
| `message`       | `str`         | Human-readable error description              |
| `status_code`   | `int \| None` | HTTP status code if caused by an HTTP error   |
| `response_body` | `str \| None` | Raw API response body for debugging           |

### Full error handling example

```python
from pybarb.connection.connection import Connection
from pybarb.metadata.station import Station
from pybarb.metrics.station.station_audiences import StationAudiences
from pybarb.utils import ApiError

try:
    conn = Connection()
    conn.connect()

    station_client = Station(conn)
    code = station_client.get_station_code("BBC1")

    sa = StationAudiences(conn)
    df = sa.get_station_audiences_flat_dataframe(
        min_transmission_date="2023-07-20",
        max_transmission_date="2023-07-20",
        station_code=code,
        panel_code=50,
        time_period_length=15,
        viewing_status="VOSDAL",
    )
    print(df.head(5).to_string(index=False))

except ApiError as e:
    print(f"API error [{e.status_code}]: {e}")
    if e.response_body:
        print(f"Response body: {e.response_body}")
except RuntimeError as e:
    print(f"Connection error: {e}")
```

---

## FAQ

>  **Answers to the questions we hear most often.**

---

**Q: Do I need a BARB subscription to use this library?**  
**A:** Yes. Tokens are issued by BARB to organisations that have a data licence.
Contact [BARB](https://www.barb.co.uk) to enquire about access. Once granted, add
your refresh token to `.env` as `BARB_REFRESH_TOKEN` and use `connect()`.

---

**Q: What does "panel code 50" mean?**  
**A:** Panel code `50` refers to the **UK Total** panel — the combined national sample that represents
all UK TV households. Other panel codes cover specific regions (e.g. London, Scotland, Wales).
Use `Panels(conn).get_panels()` to see all available panels and their codes.

---

**Q: What is the difference between VOSDAL and Consolidated viewing?**  
**A:**
- **VOSDAL** (Viewing on Same Day as Live) = people who watched a programme on the same day it aired,
  whether live or recorded and played back the same day.
- **Consolidated** = all viewing within 28 days of broadcast, including catch-up and time-shifted viewing.

For most ratings comparisons (e.g. "how did last night's show perform?"), use VOSDAL.
For a fuller picture of total reach, use Consolidated.

---

**Q: Why are audience figures in "hundreds"?**  
**A:** BARB reports audience sizes scaled to hundreds of viewers. So a value of `500` means
approximately **50,000 viewers**. This is a longstanding industry convention. To convert:
`actual_viewers ≈ audience_size_hundreds × 100`.

---

**Q: What is a TVR?**  
**A:** TVR stands for **Television Viewing Rating**. It is the percentage of the relevant population
(panel) that watched a particular programme or time slot. A TVR of `5.0` means 5% of the panel
watched. TVR is the standard currency for buying and selling TV advertising.

---

**Q: How far back does the data go?**  
**A:** This depends on your BARB data licence and the specific endpoint. Contact BARB for details
about your data access window. When testing, start with recent dates (within the last 30–60 days)
to verify your query returns results before expanding to longer ranges.

---

**Q: Can I use this in a Jupyter notebook?**  
**A:** Yes. Install `pybarb` as normal, place your `.env` file in the same folder as your
notebook (or set environment variables in the notebook cell), and use the same code examples.
The DataFrame output renders as a formatted table in Jupyter automatically.

---

**Q: Can I export the data to Excel?**  
**A:** Yes. Once you have a DataFrame, use:
```python
df.to_excel("output.xlsx", index=False)
```
You will need the `openpyxl` library: `pip install openpyxl`.

---

**Q: How do I know when the last data update was?**  
**A:** Most endpoints accept a `last_updated_greater_than` parameter (ISO datetime string) that
lets you fetch only records updated after a given timestamp. This is useful for incremental
pipeline loads — store the timestamp of your last run and pass it on the next run to get only new
or changed records.

---

**Q: Why does `get_station_code("BBC One")` raise a `station_not_found` error?**  
**A:** Station names must match exactly as they appear in the BARB data. Use
`Station(conn).list_stations()` to print all available station names and find the exact spelling
(e.g. `"BBC1"` rather than `"BBC One"`).

---

**Q: The script takes a long time — is it frozen?**  
**A:** Large date ranges can return thousands of pages of data. Add some progress output to your
script so you can see it is working:
```python
page = 1
while x_next:
    ...
    print(f"Fetched page {page} ({len(df)} total rows so far)")
    page += 1
```

---

## Glossary

>  **New to TV measurement or APIs?** Here are plain-English definitions for the key terms
> used throughout this documentation.

| Term | Plain-English Meaning |
|---|---|
| **API** | A way for programs to talk to each other over the internet. The BARB API is a service that lets your Python code request audience data from BARB's servers. |
| **SDK** | Software Development Kit — a ready-made library of code that makes it easier to use an API. Instead of writing complex HTTP requests yourself, you just call simple Python methods. |
| **DataFrame** | A table of data in Python (provided by the `pandas` library). Like a spreadsheet, it has rows and columns and can be filtered, sorted, and exported to CSV or Excel. |
| **Access Token** | A temporary password (usually valid for 1 hour) that proves you are allowed to use the BARB API. The SDK stores and reuses it when you call `connect()`, and can automatically refresh it using a refresh token when it expires. |
| **Refresh Token** | A longer-lived token used to obtain a new access token when the current one expires — without needing to log in again. |
| **Panel** | A representative sample of UK households whose TV viewing is measured by BARB. Results from the panel are weighted to represent the full UK population. |
| **Panel Code** | A number identifying which BARB panel to query (e.g. `50` = UK Total). |
| **Station Code** | A number identifying a specific TV channel/station (e.g. BBC1 has its own code). |
| **VOSDAL** | "Viewing on Same Day as Live" — viewing that happened on the same day the programme was broadcast (as opposed to catch-up or recorded viewing). |
| **Consolidated** | Viewing figures that include catch-up and recorded viewing within 28 days of broadcast, in addition to live viewing. |
| **TVR** | Television Viewing Rating — the percentage of the panel's population who watched a programme or time slot. A TVR of 10 means 10% of the panel watched. |
| **Audience Size (hundreds)** | The estimated number of viewers, expressed in hundreds. A value of `500` means approximately 50,000 viewers. |
| **x-next / Pagination** | When there are too many records to return at once, the API splits results into pages. `x-next` is the link to the next page. The SDK can follow these links automatically. |
| **Spot** | A single advertisement placement in a commercial break. |
| **Spot Impact** | The audience figures for a specific advertisement spot — i.e. how many people saw that particular ad. |
| **`.env` file** | A plain text file (named `.env`) where you store optional configuration values (for example log level). The library reads this file automatically so you never have to hard-code settings in your code. |
| **Rate Limiting (429)** | The API limits how many requests can be made in a short period. If you exceed this, it responds with a 429 error. The SDK automatically waits and retries when this happens. |

---

## License

This project is licensed under the [MIT License](https://opensource.org/licenses/MIT).

