# Stremshare External API (`/api/v1`)

A stable, versioned HTTP API for managing groups, content, smart lists,
templates, addon settings, and external imports from outside the web
app. Built so tools like Claude Code (or any AI assistant that can make
HTTP requests) can drive list building end-to-end without going through
the React UI or simulating a session.

This document is the single source of truth for the `/api/v1` surface.
Everything under `/api/v1` is authenticated with an **API key**; the web
app's own `/api/*` routes (which use cookie + JWT) are unchanged.

> **Live copies of this document:**
> - `https://stremshare.com/docs/api`: rendered HTML
> - `https://stremshare.com/docs/api.md`: raw markdown (ideal for
>   pasting into an AI assistant or fetching programmatically)

---

## Contents

1. [Quick start](#quick-start)
2. [Authentication](#authentication)
3. [Scopes](#scopes)
4. [Conventions](#conventions)
5. [Endpoints](#endpoints)
   - [Me / ping](#me--ping)
   - [Groups](#groups)
   - [Content](#content)
   - [Addon settings](#addon-settings)
   - [Discovery (list builder)](#discovery-list-builder)
   - [Smart lists](#smart-lists)
   - [External imports](#external-imports)
   - [Templates](#templates)
   - [Template auto-switch rules](#template-auto-switch-rules)
   - [AI Curator](#ai-curator)
   - [Starter packs](#starter-packs)
6. [Filter config reference](#filter-config-reference)
7. [Recipes](#recipes)
8. [Troubleshooting](#troubleshooting)

---

## Quick start

1. Log into the web app.
2. Go to **Account → API Keys → New API Key**.
3. Give it a name and select scopes (use "Select all" for a Claude-style key).
4. Copy the raw key. **It's shown exactly once**.
5. Verify:

   ```bash
   curl -H "Authorization: Bearer ss_live_..." \
     https://stremshare.com/api/v1/me
   ```

   You should see a JSON response with your user ID and the key's scopes.

---

## Authentication

### API key format

```
ss_live_<32 bytes base64url>
```

- `ss_live_` is a fixed prefix. It makes keys greppable in logs and picks
  up in GitHub secret scanners.
- The full key is shown **once** at creation time. Only a SHA-256 hash is
  stored, so losing the plaintext means rotating the key.
- The first 12 characters (`ss_live_xxxx`) are the "key prefix" and are
  displayed in the UI so you can identify keys later.

### Sending the key

Every request to `/api/v1/*` must include:

```
Authorization: Bearer ss_live_...
```

Missing, malformed, expired, or revoked keys get a `401`. A key whose
scopes don't match what the endpoint requires gets a `403`.

### Beta gate

The entire `/api/v1` surface is currently in **closed beta**. Every
route additionally requires the key owner's account to have beta
features enabled. If your account isn't flagged, every call returns:

```
403 { "error": { "code": "BETA_ONLY", "message": "This feature is currently in closed beta. Contact the owner for access." } }
```

Contact the site owner to get your account flagged.

### Key acts as its owner

An API key operates **as the user who created it**. Every request is
subject to the same group-role checks (owner / contributor / watcher) as
a browser session for that user. There is no separate "API user": if
you create a key on account A, it can only see account A's groups.

### Managing keys

In the UI (`/account` → API Keys):

- Create, list, revoke
- Scopes can be edited on existing keys
- Revocation is instant (no cache to bust)

---

## Scopes

Scopes are checked per-endpoint. A key can have any subset. The `*`
wildcard scope grants everything.

| Scope                     | Grants                                              |
|---------------------------|-----------------------------------------------------|
| `groups:read`             | List groups, read group details                     |
| `groups:write`            | Create groups, update metadata, change visibility   |
| `groups:delete`           | Delete groups                                       |
| `content:read`            | List content in a group                             |
| `content:write`           | Add content, remove content, reorder                |
| `smart_lists:read`        | Read smart lists and their configs                  |
| `smart_lists:write`       | Create, update, delete, manually sync smart lists   |
| `addon_settings:read`     | Read the user's Stremio addon settings              |
| `addon_settings:write`    | Update addon settings (enabled groups, order, etc.) |
| `discover:read`           | List-builder discovery (TMDB genres, preview, etc.) |
| `import:write`            | Import lists from Trakt / Letterboxd / MDBList       |
| `templates:read`          | List/read addon templates, rules, activation log    |
| `templates:write`         | Create, update, activate, duplicate templates; manage auto-switch rules |
| `templates:delete`        | Delete templates                                    |
| `starter_packs:subscribe` | Subscribe to a starter pack                         |
| `*`                       | All of the above                                    |

Group-role checks layer **on top of** scopes. Example: `content:write`
plus group-member status lets you add content, but deleting a group
still requires `groups:delete` AND owner role.

---

## Conventions

### Base URL

Production: `https://stremshare.com/api/v1`

Local development: `http://localhost:3000/api/v1`

### Success envelope

```json
{
  "data": { ... },
  "meta": { ... }        // optional: counts, pagination, echoed params
}
```

For list endpoints, `data` is an array.

### Error envelope

```json
{
  "error": {
    "code": "MACHINE_READABLE_CODE",
    "message": "Human-readable description",
    "details": { ... }   // optional, e.g. required vs. granted scopes
  }
}
```

Common codes:

| Code                  | HTTP | Meaning                                         |
|-----------------------|------|-------------------------------------------------|
| `NO_API_KEY`          | 401  | Authorization header missing or malformed       |
| `INVALID_API_KEY`     | 401  | Key not found, revoked, or expired              |
| `INSUFFICIENT_SCOPE`  | 403  | Key lacks one or more required scopes           |
| `BETA_ONLY`           | 403  | Key owner's account isn't beta-enabled          |
| `AI_BETA_ONLY`        | 403  | An AI feature (the AI Curator) is in a closed beta the key owner isn't in; see [AI Curator](#ai-curator) |
| `NOT_MEMBER`          | 403  | User is not a member of the group               |
| `NOT_CONTRIBUTOR`     | 403  | Contributor-or-owner role required              |
| `FORBIDDEN`           | 403  | Action not allowed (e.g. deleting another user's content) |
| `NOT_FOUND`           | 404  | Resource does not exist                         |
| `INVALID_INPUT`       | 400  | Request body or query params failed validation  |
| `PLAN_REQUIRED`       | 402  | The account's plan doesn't include this; see [Plans](#plans-and-402-plan_required) |
| `RATE_LIMITED`        | 429  | Too many requests; see [Rate limits](#rate-limits) |
| `UNAVAILABLE`         | 503  | A dependent service (e.g. sync) is offline      |
| `INTERNAL`            | 500  | Unexpected server error                         |

Resource-specific codes (`TEMPLATE_IS_ACTIVE`,
`STARTER_PACK_HAS_NO_GROUPS`, …) are documented inline with their
endpoints.

### Plans and `402 PLAN_REQUIRED`

Every account is on a plan: free (the default; tier value `free`) or
**Supporter** (the API's tier value is `plus`). Supporter isn't available to
buy yet; the Stremshare team grants it. A handful of calls do more on
Supporter, and a free account asking for one of those gets a `402` (never a
`403`) with the feature it needs in `details`:

```json
{
  "error": {
    "code": "PLAN_REQUIRED",
    "message": "This feature requires an upgraded plan.",
    "details": { "feature": "groups_unlimited", "limit": 50, "current": 50 }
  }
}
```

| Feature            | Where it bites |
|--------------------|----------------|
| `groups_unlimited` | `POST /groups`: a free account may **own** at most the free limit (a server setting, default 50) of groups (the AI curator's own groups don't count). `details` carries `limit` and `current`, so read the number from there. Accounts already over the limit keep their groups; they just can't create another. **Not capped at all:** Supporter accounts, site admins, and accounts that already owned the free limit or more when the limit was introduced (kept unlimited permanently). `GET /me` → `data.plan.groupsUnlimited` says which applies to you |
| `curator_daily`    | `PATCH /curator` with `cadence: "daily"`. Free accounts can pick `weekly` or `monthly` |
| `curator_profiles` | `PATCH /curator?profile=<id>` turning a profile's curator **on**, and `POST /curator/refresh?profile=<id>`. A profile's own curator is a Supporter feature; the account's curator stays free |
| `curator_dayparts` | `PATCH /curator` with `dayparts: true` (different rows for mornings and evenings). Turning it **on** needs Supporter; resending the stored `true` or turning it off is never refused |

Nothing is written when a call answers `402`, not even the other fields in
the same body. `GET /me` reports the key owner's plan (`data.plan`), so a
client can check up front instead of discovering the limit by hitting it.
A `402` is not retryable; it needs the account's plan to change.

Supporter also raises the **AI search daily cap**: the app's natural-language
search (Add Content → Ask) and the Stremio addon's AI search row. That is a
quota, not a `402`, and it is not an `/api/v1` route: free accounts get
**60** AI searches a day, Supporter accounts **300** (server settings
`AI_SEARCH_PER_DAY` / `AI_SEARCH_PER_DAY_PLUS`; only searches that actually
reach the model count, and repeats of an already-answered phrase are free).
Over the cap the app answers `429 AI_QUOTA_EXCEEDED` with the account's own
cap (`perDay`) and the Supporter one (`plusPerDay`), and the addon row comes back
empty. A plan that lapses drops to the free cap on the next search.

### Rate limits

Limits are applied **per API key** (not per IP), on a fixed one-minute
window:

| Limit     | Default        | Applies to                                       |
|-----------|----------------|--------------------------------------------------|
| General   | 120 req/min    | Every `/api/v1` route                            |
| Expensive | 10 req/min     | `POST /discover/preview`, `POST /discover/execute`, `POST /groups/:id/import`, `POST /smart-lists/:id/sync` |

The expensive limit stacks on top of the general one: a request to an
expensive endpoint counts against both windows. These four endpoints
fan out to external services (TMDB, OMDB, list scrapers), hence the
tighter budget.

Exceeding a limit returns `429` with the standard error envelope and a
`Retry-After` header (seconds):

```json
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "API rate limit exceeded, please slow down",
    "details": { "retry_after": 42 }
  }
}
```

Every response also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`
and `X-RateLimit-Reset` (ISO timestamp) headers so clients can pace
themselves before hitting the wall.

Server operators can tune the defaults via environment variables:
`V1_RATE_LIMIT_MAX` / `V1_RATE_LIMIT_WINDOW_MS` (general) and
`V1_EXPENSIVE_RATE_LIMIT_MAX` / `V1_EXPENSIVE_RATE_LIMIT_WINDOW_MS`
(expensive).

### IDs

- Group IDs: 8-char hex (`d1728a89`)
- Smart list IDs: UUID (`5daff029-8ff6-42ef-ac2d-eb96b7acfe31`)
- Content IDs: integer
- Template IDs and template rule IDs: integer
- Starter pack IDs: slug (`horror-essentials`)

---

## Endpoints

### Me / ping

#### `GET /api/v1/me`

No scopes required beyond a valid key. Useful for verifying auth.
`plan.tier` is `free` or `plus` (Supporter); `plan.features` lists what the plan unlocks
(see [Plans](#plans-and-402-plan_required)); `plan.expiresAt` is null for a
plan with no end date. `plan.groupsUnlimited` is `true` when the owned-group
limit doesn't apply to you: Supporter, or an account exempt from it (site admins,
and accounts that already owned the free limit when it was introduced). `user.timezone` is the IANA time zone your
[auto-switch rules](#template-auto-switch-rules) and the AI Curator's
mornings/evenings switch run in. It is `null` (read as UTC) until a browser signed
in to the website has reported one. `ai` says which AI features you can use right
now: `search` and `curator` are each `available`, `beta` (the feature is in a
closed beta you're not in, so its endpoints answer `403 AI_BETA_ONLY`) or `off`
(not running on this server, so its endpoints answer `404`); `beta` is `true`
when your account is in the AI beta.

```bash
curl -H "Authorization: Bearer $KEY" https://stremshare.com/api/v1/me
```

```json
{
  "data": {
    "user":    { "id": 2, "username": "moviefan", "email": "...", "timezone": "America/New_York" },
    "plan":    { "tier": "free", "expiresAt": null, "features": [], "groupsUnlimited": false },
    "ai":      { "search": "available", "curator": "available", "beta": true },
    "api_key": { "id": 1, "name": "Claude Code", "prefix": "ss_live_xxxx", "scopes": [...] }
  }
}
```

---

### Groups

#### `GET /api/v1/groups`
**Scopes:** `groups:read`
**Returns:** every group the user is a member of, with their role.

#### `POST /api/v1/groups`
**Scopes:** `groups:write`
**Body:** `{ "name": "...", "description": "...?", "is_public": false }`
**Returns:** the created group (user auto-added as owner, auto-enabled in their addon).
**Plan limit:** a free account may own at most the free limit (a server
setting, **default 50**; the 402's `details.limit` is the live number) of
groups. Groups the app made for you don't count: the AI curator's shelves, your Up Next group and
your My Recommendations / Because You Watched groups. At the limit this answers
`402 PLAN_REQUIRED` with `details: { feature: "groups_unlimited", limit, current }`
and creates nothing. Supporter accounts have no limit, and neither do site admins or
accounts that already owned the free limit when it was introduced (grandfathered
permanently)
(`GET /me` → `plan.groupsUnlimited`).

```bash
curl -H "Authorization: Bearer $KEY" \
     -H "Content-Type: application/json" \
     -d '{"name":"80s Action","description":"Decade + genre filter"}' \
     https://stremshare.com/api/v1/groups
```

#### `GET /api/v1/groups/:id`
**Scopes:** `groups:read`
**Auth:** member of the group, OR the group is public.

#### `PATCH /api/v1/groups/:id`
**Scopes:** `groups:write`
**Role:** owner or contributor.
**Body:** any subset of `{ name, description, genres, is_public }`.

#### `PUT /api/v1/groups/:id/visibility`
**Scopes:** `groups:write`
**Role:** owner only.
**Body:** `{ "is_public": true | false }`

#### `DELETE /api/v1/groups/:id`
**Scopes:** `groups:delete`
**Role:** owner only.
Cascades: deletes content, members, invites, and smart lists belonging to the group.

---

### Content

Content is always scoped to a group.

#### `GET /api/v1/groups/:id/content`
**Scopes:** `content:read`
**Query:** `type=movie|series`, `limit`, `offset`, `genre`, `addedBy`, `search`

#### `POST /api/v1/groups/:id/content`
**Scopes:** `content:write`
**Role:** any member.

Two shapes accepted:

```json
{ "contentId": "tt1234567" }
```

or bulk (up to 200 items per request):

```json
{
  "items": [
    { "contentId": "tt0111161" },
    { "contentId": "tt0068646", "displayOrder": 5 },
    "tt0071562"
  ]
}
```

Each item is enriched via OMDB (title, poster, year, etc.) and broadcast
to other connected clients as a socket event.

> Don't know the IMDB ID? Use
> [`GET /discover/search`](#get-discoversearch) to resolve titles to
> IMDB IDs first.

**Returns:**

```json
{
  "data": {
    "added":   [{ "input": "tt0111161", "imdb_id": "tt0111161", "info": {...}, "content_id": 42 }],
    "skipped": [{ "input": "tt0068646", "reason": "duplicate" }]
  },
  "meta": { "added_count": 1, "skipped_count": 1 }
}
```

#### `DELETE /api/v1/groups/:id/content/:contentId`
**Scopes:** `content:write`
**Role:** member. Permission enforced: you can only delete content you
added, unless you're the group owner.

#### `PUT /api/v1/groups/:id/content/reorder`
**Scopes:** `content:write`
**Role:** owner only.
**Body:** `{ "order": [{ "id": 42, "display_order": 10 }, ...] }`

---

### Addon settings

Per-user, not group-scoped.

#### `GET /api/v1/addon-settings`
**Scopes:** `addon_settings:read`

#### `PATCH /api/v1/addon-settings`
**Scopes:** `addon_settings:write`

PATCH is **merge-based**: send only the fields you want to change.

Updatable fields:

| Field                    | Type             | Notes                                  |
|--------------------------|------------------|----------------------------------------|
| `enabled_groups`         | `string[]`       | Group IDs visible in the Stremio addon. An empty array means **every** group you belong to is shown (the never-configured default), not none |
| `group_order`            | `string[]`       | Display order                          |
| `allowed_stream_groups`  | `string[]`       | Groups that may serve stream options   |
| `random_order_groups`    | `string[]`       | Groups with randomized content order   |
| `group_catalog_settings` | `object`         | `{ "<groupId>": { movies, series, collection, combined } }` |
| `use_stream_video`       | `boolean`        | Global system-video toggle             |
| `stream_feedback_mode`   | `string \| null` | What the "Add to List" stream does after adding: `webpage` (a confirmation page that jumps back into Stremio) or `video`. `null` falls back to `use_stream_video`; sending `null` leaves the stored choice as-is |
| `enable_scrobbling`      | `boolean`        | Global scrobbling toggle               |
| `poster_provider`        | `string \| null` | Active rating-poster provider: `rpdb`, `openposterdb`, `top_posters`, `betterposters`, `xrdb` or `tmdb_regional`. Sending `null` **clears** it (back to the default OMDB/TMDB posters). The provider's API key is managed separately, under Connections |
| `poster_language`        | `string \| null` | 2-letter ISO 639-1 code for catalog row labels and `tmdb_regional` poster lookups. Sending `null` **clears** it |
| `poster_rated_episodes`  | `boolean \| 0\|1` | Opt in to provider episode thumbnails on detail pages (needs a provider that renders them) |
| `poster_rated_artwork`   | `boolean \| 0\|1` | Opt in to provider logos/backdrops on detail pages (needs a provider that renders them) |
| `hide_watched_global`    | `boolean`        | Account-level default for hiding already-watched titles from catalogs. Movies hide on any watch; a series hides only once every released episode is watched |
| `hide_watched_groups`    | `object`         | Per-group override map `{ "<groupId>": "hide" \| "show" }`. A key wins over `hide_watched_global` in either direction; **omit** a group to inherit it. Send `{}` to clear every override; unknown values are dropped |

> If you find yourself flipping addon settings between whole
> configurations (e.g. "normal" vs "kids weekend"), use
> [Templates](#templates) instead. They snapshot and restore the whole
> settings object in one call.

---

### Discovery (list builder)

All require `discover:read`. Reference-data endpoints return the
upstream TMDB shape unchanged for ergonomic filter construction.

| Method | Path                                 | Notes |
|--------|--------------------------------------|-------|
| GET    | `/discover/genres?mediaType=movie`   | TMDB genre IDs + names |
| GET    | `/discover/providers?region=US&mediaType=movie` | Streaming providers |
| GET    | `/discover/languages`                | All TMDB languages |
| GET    | `/discover/certifications?country=US` | Ratings (G, PG, R, etc.) |
| GET    | `/discover/people?q=nolan`           | Search actors/directors |
| GET    | `/discover/keywords?q=cyberpunk`     | Search keywords |
| GET    | `/discover/networks?q=hbo`           | TV networks |
| GET    | `/discover/companies?q=a24`          | Production companies |
| GET    | `/discover/search?q=inception`       | Title → IMDB ID lookup (see below) |
| POST   | `/discover/preview`                  | First-page preview (see below) |
| POST   | `/discover/execute`                  | Full multi-page discovery |

#### `GET /discover/search`

Search movies/series by name and get back IMDB IDs you can feed
directly into [content add](#post-apiv1groupsidcontent).

**Query:** `q` (required), `mediaType=movie|tv|both` (default `both`),
`year`, `limit`

```json
{
  "data": [ { "imdb_id": "tt1375666", "title": "Inception", ... } ],
  "meta": { "query": "inception", "mediaType": "both", "year": null, "count": 1 }
}
```

#### `POST /discover/preview`

**Body:**

```json
{ "filter_config": { /* see filter config reference */ } }
```

**Returns:** `{ items[], totalResults, totalPages, page, excludedCount, noImdbCount }`

The response `meta.source` is `tmdb` or `anilist`; anime-mode filter
configs are routed to AniList automatically (see
[Anime mode](#anime-mode-anilist)).

#### `POST /discover/execute`

**Body:**

```json
{
  "filter_config": { ... },
  "max_pages": 5,        // capped at 20
  "start_page": 1
}
```

---

### Smart lists

Smart lists are stored filter configs that auto-sync matched items into
a group on a schedule. They're the backbone for "keep this collection
fresh" use cases.

Group-scoped (composed with group-role middleware):

| Method | Path                                    | Scopes             | Role                        |
|--------|-----------------------------------------|--------------------|-----------------------------|
| GET    | `/groups/:id/smart-lists`               | `smart_lists:read` | member or public group      |
| POST   | `/groups/:id/smart-lists`               | `smart_lists:write`| owner or contributor        |

Top-level by UUID (authorization resolved from the list's `group_id`):

| Method | Path                         | Scopes              | Role                    |
|--------|------------------------------|---------------------|-------------------------|
| GET    | `/smart-lists/:id`           | `smart_lists:read`  | member of owning group  |
| PATCH  | `/smart-lists/:id`           | `smart_lists:write` | owner or contributor    |
| DELETE | `/smart-lists/:id`           | `smart_lists:write` | owner or contributor    |
| POST   | `/smart-lists/:id/sync`      | `smart_lists:write` | owner or contributor    |

#### Create body

```json
{
  "name": "Dramas 2020+",
  "description": "optional",
  "filter_config": { /* see filter config reference */ },
  "sync_frequency": "daily",
  "sync_mode": "append",
  "is_active": true,
  "page_progression_enabled": false,
  "source_type": "list_builder"
}
```

- **`sync_frequency`:** `manual` (default), `hourly`, `every_6_hours`,
  `daily`, `every_3_days`, `weekly`.
- **`sync_mode`:** `append` (default: new matches are added, existing
  content stays) or `replace` (each sync makes the group's
  list-derived content mirror the current results).
- **`source_type`:** `list_builder` (default), `trakt`, `letterboxd`,
  `mdblist`, `flixpatrol`, `predb`. External source types are normally
  set by the [import endpoint](#external-imports), and creating one
  manually is uncommon. `imdb` is no longer accepted (see
  [IMDB imports](#imdb-imports-are-retired)).
- **`page_progression_enabled`:** when `true`, each sync fetches the
  *next* page of results instead of re-fetching page 1, which is useful for
  gradually deepening a large catalog.

PATCH accepts the same fields (all optional; send only what changes).

#### Sync response

```json
{
  "data": { "added": 6, "alreadyInGroup": 0, "total": 6 },
  "meta": { "id": "<uuid>", "name": "..." }
}
```

---

### External imports

One endpoint wraps all four external sources. Choose between a one-shot
bulk import or a recurring auto-syncing smart list.

#### `POST /api/v1/groups/:id/import`

**Scopes:** `import:write`
**Role:** any member.

**Body:**

```json
{
  "source": "trakt" | "letterboxd" | "mdblist",
  "url": "https://...",
  "create_smart_list": false,
  "sync_frequency": "daily",        // only when create_smart_list=true
  "smart_list_name": "optional"     // only when create_smart_list=true
}
```

**One-shot response** (when `create_smart_list` is `false` or omitted):

```json
{
  "data": { "imported": 81, "skipped": 0, "failed": 0 },
  "meta": { "source": "mdblist", "url": "...", "fetched": 81 }
}
```

**Smart list response** (when `create_smart_list: true`): a smart list
is created and the first sync runs immediately:

```json
{
  "data": {
    "smart_list":  { /* full smart list object */ },
    "sync_result": { "added": 81, "alreadyInGroup": 0, "total": 81 }
  }
}
```

**URL formats by source:**

| Source     | Example                                                              |
|------------|----------------------------------------------------------------------|
| Trakt      | `https://trakt.tv/users/<user>/lists/<list-slug>`                    |
| Letterboxd | `https://letterboxd.com/<user>/list/<list-slug>/`                    |
| MDBList    | `https://mdblist.com/lists/<user>/<list-slug>/`                      |

#### IMDB imports are retired

IMDB now serves an interactive "Human Verification" challenge to all
automated traffic, so direct IMDB list imports are no longer supported.
`source: "imdb"` returns **410 Gone** with code `IMDB_IMPORT_RETIRED`,
and `source_type: "imdb"` is rejected on smart-list create.

Two workarounds:

1. Search [MDBList](https://mdblist.com) for a mirror of the list and
   import that instead.
2. Export the list from IMDB as CSV (the **Export** option on the list
   page) and upload it through the web UI's file-upload import.

---

### Templates

A **template** is a named, saved snapshot of your entire addon
configuration: which groups are enabled, their order, stream/catalog
settings, etc. Exactly one template is active at a time; activating one
writes its config into your live addon settings (and pushes to Stremio
Link if connected). Templates let you flip your whole addon between
presets ("Default", "Halloween", "Kids") in a single call, and pair with
[auto-switch rules](#template-auto-switch-rules) for calendar-driven
switching.

Templates created by [subscribing to a starter pack](#starter-packs)
are **pack-derived** (`derived_from_collection_id` set) and read-only;
duplicate one to get an editable copy.

#### Template object

```json
{
  "id": 12,
  "user_id": 2,
  "name": "Halloween",
  "is_active": false,
  "is_managed": false,
  "is_curator": false,
  "curator_profile_id": null,
  "derived_from_collection_id": null,
  "activation_rule": null,
  "enabled_groups_count": 5,
  "config": { /* detail endpoints only — see below */ },
  "created_at": "...", "updated_at": "...", "last_activated_at": null
}
```

`is_curator` is `true` on a template the [AI Curator](#ai-curator) builds
(it is also `is_managed`). Its **group list is the curator's**: every plan
rewrites which groups are on it and their order, so the API refuses to change
them (see `PATCH` below). Steer the curator instead. `curator_profile_id` is
the profile whose own curator owns it, or `null` for your account's curator;
a profile's curator template is switched in by that profile's curator and
can't be activated for your whole account.

`config` shape (same field meanings as [addon settings](#addon-settings)):

```json
{
  "enabled_groups": ["d1728a89", "..."],
  "group_order": ["d1728a89", "..."],
  "allowed_stream_groups": [],
  "stream_order": [],
  "random_order_groups": [],
  "group_catalog_settings": { "d1728a89": { "movies": true, "series": true } },
  "hide_watched_groups": { "d1728a89": "hide" }
}
```

`hide_watched_groups` is a per-group map (`{ "<groupId>": "hide" | "show" }`)
of the template's Hide Watched choices; activating the template applies it to
your live addon settings the same way the rest of `config` does. It's
currently **read-only through the v1 API**: `POST /api/v1/templates`
snapshots it from your current settings and `GET` returns it, but
`PATCH /api/v1/templates/:id` does not accept it (see below).

#### `GET /api/v1/templates`
**Scopes:** `templates:read`
All of the user's templates. List entries omit `config`.

#### `POST /api/v1/templates`
**Scopes:** `templates:write`
**Body:** `{ "name": "...", "config": { ... }? }`. Name is required
(1–100 chars); unknown top-level keys are rejected. The name
`Saved configuration` (any case) is reserved for your auto-saved setup →
`400 RESERVED_TEMPLATE_NAME`.

- **Omit `config`** → the server snapshots your *current* addon
  settings into the template. This is how you "save current state".
- **Provide `config`** → `enabled_groups` must only contain groups you
  are a member of, else `409 TEMPLATE_GROUPS_NOT_SUBSET` (with
  `details.missing_group_ids`). `group_order` is set to match.

**Returns:** `201` with the template (including `config`).

#### `GET /api/v1/templates/:id`
**Scopes:** `templates:read`
Template + `config` + a hydrated `groups` array
(`{ id, name, members_count, items_count }`, order preserved).

#### `PATCH /api/v1/templates/:id`
**Scopes:** `templates:write`
**Body:** any of `name`, `enabled_groups`, `allowed_stream_groups`,
`stream_order`, `random_order_groups`, `group_catalog_settings`
(at least one required). Stream fields must be subsets of the effective
`enabled_groups`, else `400 STREAM_FIELDS_NOT_SUBSET`. `hide_watched_groups`
is **not** an accepted field here. It can only be changed by activating a
template whose config carries the choices you want (or via the session-auth
web UI).

- Pack-derived templates are read-only → `409 TEMPLATE_IS_PACK_DERIVED`.
- AI Curator templates (`is_curator: true`): `enabled_groups` (or
  `group_order`, `addon_group_order`, `curator_group_name_overrides`,
  `group_name_overrides`) →
  `409 TEMPLATE_IS_CURATOR`, with the offending keys in `details.fields`;
  nothing is written. A **changed** `name` is refused the same way (`name`
  appears in `details.fields`), since the curator names its templates; resending
  the current name is fine. The stream fields still save. On these
  templates stream-field entries for a group the curator has since dropped
  are **silently removed** instead of `400 STREAM_FIELDS_NOT_SUBSET`.
- Renaming the auto-saved "Saved configuration" template (or a profile's
  remembered catalog) → `409 TEMPLATE_IS_MANAGED`; renaming any other template
  **to** that name → `400 RESERVED_TEMPLATE_NAME`.
- Editing the currently **active** template re-syncs your live addon
  settings; if that push fails the response still succeeds but includes
  a top-level `"sync_warning": "visibility_sync_failed"`.

#### `DELETE /api/v1/templates/:id`
**Scopes:** `templates:delete`
Refuses to delete: pack-derived templates
(`409 TEMPLATE_IS_PACK_DERIVED`), the active template
(`409 TEMPLATE_IS_ACTIVE`), or your last remaining template
(`409 TEMPLATE_LAST_REMAINING`).

#### `POST /api/v1/templates/:id/activate`
**Scopes:** `templates:write`
**Body:** none. Makes the template active: joins/leaves groups to match
it, writes its config into live addon settings, pushes to Stremio Link,
and may auto-save your previous state if it had diverged.

A profile's AI Curator template (`curator_profile_id` set) answers
`409 TEMPLATE_IS_CURATOR` unless it is already active (then the usual
`already_active: true` no-op), because that profile's curator switches it in.
Your account's own curator template activates normally.

**Returns:**

```json
{
  "data": {
    "template_id": 12,
    "activated": true,
    "already_active": false,
    "joined": ["<group-id>"],
    "left": [],
    "auto_saved": null,
    "catalog_size": 7
  }
}
```

#### `POST /api/v1/templates/:id/duplicate`
**Scopes:** `templates:write`
**Body:** none. Deep-copies the template as `"<name> (Copy)"`
(`(Copy 2)`, … on collision). The copy is never pack-derived, so this is
the escape hatch for editing a starter-pack template. AI Curator templates
can't be duplicated (`409 TEMPLATE_IS_CURATOR`): the curator retires their
groups over time, so a copy would go stale.

---

### Template auto-switch rules

Rules attach to a template and automatically activate it based on the
calendar/clock, evaluated in the user's timezone (`GET /me` →
`user.timezone`; UTC if unset, and the website reports your browser's zone when
you open the dashboard) by a scheduler that ticks roughly every 15 minutes.
When multiple enabled rules match, the highest `priority` wins.

#### Rule object

```json
{
  "id": 3,
  "template_id": 12,
  "rule_type": "holiday",
  "config": { "holiday_id": "us_halloween", "days_before": 14, "days_after": 1 },
  "priority": 100,
  "enabled": true,
  "created_at": "...", "updated_at": "..."
}
```

#### Rule types and their `config`

| `rule_type`         | Config fields |
|---------------------|---------------|
| `date_range`        | `start`/`end` (`YYYY-MM-DD`, end ≥ start), optional `time_start`/`time_end` (`HH:MM`) |
| `annual_date_range` | `month_day_start`/`month_day_end` (`MM-DD`; may wrap the year boundary, e.g. `12-28`→`01-05`), optional `time_start`/`time_end` |
| `day_of_week`       | `days` (int[] 0–6, **Sun=0**), optional `time_start`+`time_end` (both or neither; window may cross midnight) |
| `holiday`           | `holiday_id`, `days_before` (0–60), `days_after` (0–60) |

Valid `holiday_id` values (US fixed-date holidays): `us_new_years_day`,
`us_valentines_day`, `us_st_patricks_day`, `us_april_fools`,
`us_cinco_de_mayo`, `us_independence_day`, `us_halloween`,
`us_veterans_day`, `us_christmas_eve`, `us_christmas`, `us_boxing_day`,
`us_new_years_eve`.

#### Rule scope: account-wide, or one always-on profile

Every rule carries a `profile_id`. It is `null` by default and that is the
original behavior: the rule is **account-wide**, and when it matches, its
template is **activated** for your whole account.

Give it the id of a profile connected to Nuvio or Stremio and the rule is
scoped to that profile instead: when it matches, the template becomes that
profile's **view source** (its own addon URL starts serving the template's
config) and nothing is activated. The account's active template, and every
other profile, are untouched. Scoped rules compete only with each other, so a
profile rule can never win the account's pick (or the reverse).

Two extra refusals apply only to scoped rules:

- `409 PROFILE_NOT_BOUND`: the profile isn't connected to Nuvio or Stremio,
  so it has no addon URL of its own to point anywhere.
- `400 TEMPLATE_IS_MANAGED`: the target template is one Stremshare rewrites
  for you, so a profile following it would change without you asking.

And one applies to every rule you write: an AI Curator template
(`is_curator: true`, your account's or a profile's) can't be a rule's target
at all: `POST /template-rules` answers `409 TEMPLATE_IS_CURATOR`, since the
curator decides when its templates go live. For such a rule written before
this check, a `PATCH` answers the same only when the rule would end up
**enabled** (switching it off, and editing it while off, always work),
`DELETE` always works, and the scheduler skips it. The curator's own rules
(`created_by_curator: true`) are unaffected.

Changing a profile's view **by hand** (following a template, clearing it, or
saving its catalog) pauses that profile's scoped rules for 2 hours, so
automation can't immediately shadow what you just did. That window is
per-profile: `/template-rules/pause` still pauses the account lane only, and
neither pause quiets the other.

When a scoped rule stops matching, nothing is reverted. The last attachment
stands, the same way the account lane leaves the last activation standing.

#### Rules managed by the AI Curator

Every rule carries `created_by_curator` (boolean). It is `true` on the two
`day_of_week` rules the [AI Curator](#ai-curator) writes when its
**mornings and evenings** option (`dayparts`) is on: one for its "Day"
template (every day, `05:00`–`17:00`) and one for its "Night" template
(`17:00`–`05:00`, a window that wraps past midnight). They **never compete
with your own rules**: the scheduler only consults them when none of yours
matches, to decide which half of the curated homepage to show, so a rule of
yours always wins, at any priority (their own `priority` of `100` is
irrelevant), and the switch respects the same things the curator always
does: an active pause, a template you picked by hand, and a switched-in
profile's own curator. They are **read-only**: `PATCH` and `DELETE` on one
answer `409 RULE_MANAGED_BY_CURATOR`. They disappear when you turn `dayparts`
off, turn the curator off, or lose Supporter.

#### Endpoints

| Method | Path                          | Scopes            | Notes |
|--------|-------------------------------|-------------------|-------|
| GET    | `/template-rules?template_id=12` | `templates:read`  | Rules for one template. Pass **exactly one** of `template_id` or `profile_id` (`profile_id=7` lists that profile's scoped rules, enabled or not) |
| POST   | `/template-rules`             | `templates:write` | Body: `{ template_id, rule_type, config, priority?=100, enabled?=true, profile_id?=null }` → `201` |
| GET    | `/template-rules/:id`         | `templates:read`  | |
| PATCH  | `/template-rules/:id`         | `templates:write` | Any of `rule_type`, `config`, `priority`, `enabled`, `profile_id` (omit to keep the current scope; `null` widens it back to account-wide) |
| DELETE | `/template-rules/:id`         | `templates:write` | Returns `{ "data": { "id": 3, "deleted": true } }`. `409 RULE_MANAGED_BY_CURATOR` on a curator-managed rule (as does `PATCH`) |
| GET    | `/template-rules/log`         | `templates:read`  | Activation history. Query: `limit` (default 50), `before` (cursor). Entries: `{ id, template_id, template_name, source, rule_id, reason, profile_id, profile_name, triggered_at }`; `source` ∈ `manual`, `auto`, `subscribe`, `admin`, `profile_update`. `profile_id`/`profile_name` are set when a scoped rule attached a template to that profile's view (`profile_name` reads `null` once the profile is deleted) |
| GET    | `/template-rules/next-switch` | `templates:read`  | Predicts the next **account** auto-switch: `{ template_id, template_name, at, rule_id, source }` or `null`, as the scheduler will actually make it: nothing before a live pause ends, and with the AI Curator on, its fallback when your rules go quiet. `source` is `rule`, `curator_daypart` (the mornings/evenings half) or `curator_fallback` (the curated homepage; `rule_id: null`). `meta: { held, paused_until }` says why nothing may be due: `held` is `"paused"`, `"manual_choice"` (a template you picked by hand is live, so the curator waits until something else replaces it) or `null`. Add `?profile_id=7` to ask the same of one profile's view. Looks ahead ~400 days; windows narrower than ~3h beyond the first day may be missed by its sampling |
| GET    | `/template-rules/pause-status`| `templates:read`  | `{ "paused_until": "<ISO>"|null, "is_paused": bool }` |
| POST   | `/template-rules/pause`       | `templates:write` | Body: `{ "until": "<ISO datetime>" }`; omit `until` → 24h from now; `"until": null` → indefinitely |
| POST   | `/template-rules/resume`      | `templates:write` | Clears the pause |

---

### AI Curator

The curator re-plans your Stremio homepage on a cadence you choose. Each
run asks a language model for a set of rows, then builds them: private
**smart-list groups** for the shelves it invents, memberships in public
groups it picks, and a managed template named **AI Curator** that it
activates.

It is **opt-in and off by default**, and it can be switched off entirely
at the deployment level. **When the server has it off, every endpoint in
this section answers `404` with `CURATOR_DISABLED`**. That is the server
saying it has no curator, not a problem with your request. Don't retry it.

While the curator is in a **closed beta**, an account that isn't in it gets
**`403 AI_BETA_ONLY`** ("This feature is in beta.") from every endpoint in this
section, including the reads, and nothing is read or changed. `GET /me` →
`data.ai.curator` tells you up front: `available`, `beta` (this `403`) or `off`
(the `404`). Don't retry it; access is granted by the site owner.

One thing still works outside the beta: switching a curator **off**. A curator
that was on before the account left the beta keeps running its schedule
switches, so `PATCH /curator` with a body that only sets `enabled: false`
and/or `dayparts: false` is carried out (also while the site owner has AI
switched off) and answers `200 { data: { enabled, dayparts, lane, pending? } }`.
Any other body gets the `403`. The `403` from `GET /curator` says whether there
is anything to switch off, in `error.details`:
`{ curatorEnabled, daypartsOn, enabledLanes: [{ profileId, name }] }` (the
first two describe the lane you asked for; `enabledLanes` lists every lane
still on, `profileId` null for the account).

Scopes reuse the template scopes: `templates:read` to read,
`templates:write` for everything else. What the curator writes *is* a
template, so a key trusted to author templates is already trusted here,
and one that isn't must not be able to hand that authoring to a model.

#### Per-profile curators

Every endpoint in this section takes an optional **`?profile=<id>`** query
parameter. Without it you are talking to the **account's** curator; with a
Stremio profile id you are talking to **that profile's own** curator, with its
own on/off, cadence, instructions, plan, "Always keep" list, history and
refresh allowance, planned from that profile's own watch history and at that
profile's content ceiling. Two caps are **account-wide**, not per profile: at
most 8 profiles may have their curator on at once (`409 CURATOR_LANE_LIMIT`),
and manual refreshes are also counted summed over the account and every
profile (10 a day with Supporter, otherwise 4 a week: `429
CURATOR_ACCOUNT_REFRESH_LIMIT`; see [`POST /curator/refresh`](#post-curatorrefresh)).
Both are server defaults. `lanes` on the curator object lists the ids you
can pass. A profile that is not yours is `404` with `PROFILE_NOT_FOUND`; a
malformed id is `400`.

What a profile's curator does with the homepage it builds depends on the
profile: a profile **connected to Nuvio or Stremio** (`kind: "bound"`)
follows the template as its own add-on view, and its add-on is re-pushed; a
**switchable** profile (`kind: "switchable"`) switches in to the template,
immediately when it is the profile switched in right now, otherwise the next
time it is. A profile's own auto-switch pause and its own template rules
hold the curator back exactly as the account's hold back the account's.

A profile's curator is a **Supporter** feature (`curator_profiles`): turning one
on, or refreshing one, from a free account is `402 PLAN_REQUIRED`. A profile
curator left on when the plan lapses is simply not run until it renews.

#### Curator object

`GET /curator` and every write return the same body:

```json
{
  "data": {
    "enabled": true,
    "cadence": "weekly",
    "instructions": "no horror; family movie night on fridays",
    "keepSystemRows": true,
    "dayparts": false,
    "templateId": 42,
    "templateIsActive": true,
    "liveTemplate": { "id": 42, "name": "AI Curator" },
    "deferralReason": null,
    "canActivate": false,
    "claimPending": false,
    "lastRunAt": "2026-09-20T03:14:07Z",
    "nextRunAt": "2026-09-27T01:52:31Z",
    "lastError": null,
    "refreshing": false,
    "refreshRemainingToday": 2,
    "refreshWindow": "week",
    "refreshLimit": 2,
    "pausedUntil": null,
    "userPlan": { "tier": "free", "features": [] },
    "caps": { "maxRows": 12, "maxSmartLists": 6, "pinLimit": 9 },
    "lane": { "profileId": null, "name": "Account", "kind": "account" },
    "lanes": [
      { "profileId": null, "name": "Account", "kind": "account", "enabled": true },
      { "profileId": 31, "name": "Kids", "kind": "switchable", "enabled": false }
    ],
    "plan": {
      "id": 118,
      "headline": "Autumn, and the shows you left half-finished",
      "notes": null,
      "status": "applied",
      "createdAt": "2026-09-20T03:14:07Z",
      "model": "claude-sonnet-5",
      "upNextGroupId": "a9f3c2e1",
      "rows": [
        { "kind": "group", "groupId": "10862192", "rowId": null,
          "title": "Scene Releases: Movies", "alias": null,
          "reason": "You watch new releases the week they land.", "feedback": null,
          "required": false },
        { "kind": "smart_list", "groupId": "a1b2c3d4", "rowId": 17,
          "title": "Slow-burn scares", "alias": null,
          "reason": "You finished Hill House in three nights.", "feedback": "pinned",
          "placement": "auto" }
      ]
    }
  }
}
```

- `templateIsActive` is **not** the same as `enabled`. A template rule, an
  auto-switch pause or a manual activation can leave the curator on while
  its homepage sits on the bench. That is what the `applied_deferred`
  status means.
- `liveTemplate` is the template your account is serving right now
  (`{ id, name }`, or `null` when none). `deferralReason` says why an
  enabled curator's template is not the live one: `paused` (an auto-switch
  pause), `manual_choice_stands` (you picked another template by hand),
  `rule_match` (one of your template rules matches right now),
  `profile_lane_active` (a switched-in profile's own curator holds the
  homepage) or `account_paused` (a profile's curator held by the account's
  pause); `null` when it is live, off, or has no template yet.
  `canActivate` is `true` when [`POST /curator/activate`](#post-curatoractivate)
  would put the curator on your homepage. `claimPending` is `true` after
  turning the curator on for the first time: its first plan takes the
  homepage over when it lands. On a profile's curator, `liveTemplate` is
  `null` and `canActivate` / `claimPending` are `false`.
- `plan` is `null` until the first run lands. Its `rows` are joined to
  live state, so a row the user has since removed or vetoed is **omitted**:
  the list is what the homepage actually shows, not a snapshot of what was
  once planned.
- `rowId` is present on `smart_list` rows only; `groupId` is present on both.
- `placement` (`top` | `bottom` | `auto`) is present only on a row whose
  `feedback` is `pinned`: where that pin lands (see `POST /curator/feedback`).
- `userPlan` is your **account's** plan (`{ tier, features }`), not the
  curator plan above (named apart for exactly that reason). On Supporter the
  curator may build a homepage with higher caps (server settings, default
  16 rows / 8 AI-authored smart lists, against the free 12 / 6), can run
  `daily`, and reads a weekly "what's on" brief. A Supporter account that lapses
  keeps `cadence: "daily"` stored but is scheduled weekly until it renews,
  and may resend that stored `daily` in a `PATCH` without a `402` (only a
  CHANGE to daily is gated).
- `caps` is THIS account's row budget: `maxRows` and `maxSmartLists` are
  what the curator plans to (and trims to), `pinLimit` how many groups it
  may keep (`maxRows − 3`). Read the numbers from here rather than
  hard-coding them: they are server settings and they follow the plan.
- `keepSystemRows` (default `true`) keeps your own auto-rows on every curated
  homepage: **Up Next** is first when Up Next is enabled and not vetoed (it
  is not part of the plan, so it does not appear in `plan.rows`; the plan's
  `upNextGroupId` names it, `null` when there was no such slot), and your
  **My Recommendations** / **Because You Watched** rows are always included
  while that feature is switched on. The curator only picks where they go
  (`required: true` on the row), and they do not count against the row cap.
  A recommendation row you switched off, or one frozen because a profile got
  its own, is an ordinary group again. `false` treats them all like any
  other group: the curator may use them or leave them out.
- `dayparts` (default `false`, **Supporter**: `curator_dayparts`) gives the
  homepage different rows for mornings and evenings. The curator then keeps
  TWO templates: "AI Curator — Day" (family, kids, light and short rows)
  and "AI Curator — Night" (the rest), sharing the same groups, plus two
  read-only [curator-managed rules](#rules-managed-by-the-ai-curator) that
  switch between them at 05:00 and 17:00 in your timezone (`GET /me` →
  `user.timezone`; turning `dayparts` on needs one, see below). Plans made
  this way carry `daypart` (`morning` | `evening` | `any`) on every row, and
  the run's status is `applied_dayparts` (the curator wrote both halves; it
  refreshes whichever is live, and when neither is it puts on the half that
  fits the current hour). `templateIsActive` is true when either half is
  live. A plan whose split leaves either half with fewer than two of the
  curator's own rows (pinned and required rows don't count), or whose halves
  come out identical, is written as ONE template with no rules. Turning it
  off folds the evening rows into the Day template (in plan order), moves
  whatever showed Night onto it and deletes Night straight away; so does
  turning the curator off, and losing Supporter (at the next run or scheduler
  sweep).
- `pausedUntil` is the account's auto-switch pause (same value as
  `/template-rules/pause-status`), because that pause is what defers a
  curator activation. On a profile's curator it is **that profile's** pause.
- `lane` is the curator this body describes: `profileId` `null` and `kind:
  "account"` for the account's, otherwise the profile's id, name and `kind`
  (`bound` | `switchable`). `lanes` lists every curator you can address: the
  account first, then one per Stremio profile, each with `enabled`. On a
  profile's curator, `keepSystemRows` keeps **that profile's** own
  recommendation rows; Up Next is account-level and not a fixed slot there.
  `templateIsActive` means the profile is pointed at its curated template
  (and, for the switchable profile switched in right now, that the account
  is serving it).
- `lastRunAt`, `nextRunAt`, `pausedUntil` and `plan.createdAt` are
  **ISO-8601 UTC instants** (`2026-09-20T03:14:07Z`). Parse them, don't
  pattern-match them.
- `lastError` is a **short fixed summary**, not the provider's message:
  one of "The AI budget for today is used up.", "The AI took too long to
  answer." or "The last refresh didn't finish.". The underlying error stays
  in the server log; `GET /curator/history` is where you look for which run
  failed.
- `refreshRemainingToday` is the manual refreshes left in the **current
  window**. Despite the name, the window depends on your plan and is
  given by `refreshWindow`: `"day"` for Supporter accounts (the `curator_daily`
  feature; default 3 a day) or `"week"` otherwise (default 2 a week).
  `refreshLimit` is the window's total. The count resets on **your
  account's local day**, or its local ISO week (Monday start), the same
  boundary the cap itself is spent on (`users.timezone`, UTC if unset).
  The `429` is still the authority: the counter is read a moment before
  a concurrent run may spend from it.

#### Endpoints

| Method | Path                     | Scopes            | Notes |
|--------|--------------------------|-------------------|-------|
| GET    | `/curator`               | `templates:read`  | The curator object above. Every route here also takes `?profile=<id>` (see [Per-profile curators](#per-profile-curators)) |
| PATCH  | `/curator`               | `templates:write` | Body: `{ enabled?, cadence?, instructions?, keepSystemRows?, dayparts?, timezone? }`. Partial: omitted fields are preserved. Returns the curator object plus `firstRunQueued` (and `daypartsRunQueued` / `pending`; see below) |
| POST   | `/curator/refresh`       | `templates:write` | `202 { "data": { "queued": true, "refreshRemainingToday": 2, "refreshWindow": "day", "refreshLimit": 3 } }` |
| POST   | `/curator/feedback`      | `templates:write` | Body: `{ kind, rowId \| groupId, feedback, placement? }`. Returns the curator object |
| POST   | `/curator/activate`      | `templates:write` | Put the curator's template on your homepage now. No body. Returns the curator object. See below |
| GET    | `/curator/history`       | `templates:read`  | Query: `limit`, an integer clamped to 1–50; anything else (a repeated parameter, a decimal, a word) falls back to the default 10 |
| GET    | `/curator/groups`        | `templates:read`  | The "Always keep" picker: your groups, labelled. See below |

#### `PATCH /curator`

| Field          | Type                | Notes |
|----------------|---------------------|-------|
| `enabled`      | boolean             | Master switch. On a profile's curator (`?profile=`), turning it **on** while 8 other profiles already have theirs on is `409 CURATOR_LANE_LIMIT` (`details.limit`), nothing saved; the account's own curator never counts toward that |
| `cadence`      | enum                | `daily`, `weekly` (default), `monthly`. **`daily` needs Supporter**: a free account moving to it gets `402 PLAN_REQUIRED` (`details.feature: "curator_daily"`) and nothing in the body is saved. Resending the cadence already stored is never refused, and a malformed body is a `400` before any `402` |
| `instructions` | string \| null      | Free text the model follows verbatim, **max 500 characters**. `null` or `""` clears it |
| `keepSystemRows` | boolean           | Keep Up Next first and your recommendation rows on every homepage (default `true`). `null` is a `400` |
| `dayparts`     | boolean             | Different rows for mornings and evenings (default `false`). **Needs Supporter** to turn on: `402 PLAN_REQUIRED` (`details.feature: "curator_dayparts"`), nothing saved; resending the stored `true` or turning it off is never refused. Turning it on also **needs your time zone**: while `GET /me` shows `timezone: null` and the body carries no `timezone`, it is `409 TIMEZONE_REQUIRED`, nothing saved. Turning it on for a curator that is on starts a plan now, charged to the refresh budget like the first run (`"daypartsRunQueued": true`; `false` when the budget is spent, and the next scheduled run builds the pair). Turning it off starts folding the pair back into one template immediately **without waiting for it** (`"pending": true`; `refreshing` stays `true` until it lands). `null` is a `400` |
| `timezone`     | string              | Your IANA time zone (`"Europe/Berlin"`), written to your account when it differs (the zone rules and the mornings/evenings switch run in). Validated against the server's time-zone database, max 64 characters; anything else is a `400` before anything is saved. `null` / absent leaves it alone |

Turning it **on** from off starts a plan in the background when you have
manual refresh budget left; `"firstRunQueued": true` says the kick started,
and `refreshing` is `true` with it. That kick **is charged to the manual
refresh budget**, exactly as a `POST /curator/refresh` would be.

When the budget is already spent the kick is skipped (you get
`"firstRunQueued": false` with `"refreshRemainingToday": 0`), and what
happens next depends on whether the curator has ever run for you:

- **First-ever enable:** the curator is due immediately, so the scheduler
  picks the first plan up on its next tick.
- **Re-enable** (it has run before): the schedule it already had stands.
  Turning it off and on again never makes the next plan come sooner. A
  schedule that had already come due is simply due again.

Either way the account is enabled and scheduled.

Turning the account's curator on also **switches your homepage to it**, the
same as picking its template by hand (it pauses auto-switching for 2 hours,
like any manual pick). The answer's `homepageSwitch` says how:

- `"now"`: the curator already had a template (you turned it back on), and it
  is live now.
- `"queued"`: as `"now"`, but an update was in progress, so the switch waits
  for it and lands as soon as it finishes (`claimPending` is `true` until
  then; poll `GET /curator`).
- `"first_plan"`: this is its first run, so there is no template yet; the
  first plan takes the homepage over when it lands (`claimPending` is
  `true` until then). Picking another template yourself, pausing
  auto-switching, one of your template rules switching templates, or
  adding or editing one of your rules before it lands cancels that; so
  does a day passing.
- `null`: nothing to switch (it was already live, it is a profile's curator,
  a Stremio profile is switched in on the account, or the request did not
  turn it on). With a profile switched in, the curator waits as it would for
  any template you picked (see `deferralReason`); use
  [`POST /curator/activate`](#post-curatoractivate) to switch anyway.

Turning it **off** only stops future scheduling. The managed template, the
groups it created and the rows it chose all stay exactly where they are
until you remove them, except a mornings/evenings pair, which is folded
back into the one template (its read-only rules deleted) so nothing keeps
switching for a curator that is off. A plan being applied at that moment
stops before it changes anything you can see. If one is mid-apply, the
switch is saved straight away and the fold waits for it: the answer carries
`"pending": true` and `refreshing` stays `true` until the fold lands.

#### `POST /curator/activate`

Puts the account curator's template on your homepage now, as if you had
picked it by hand (auto-switching pauses for 2 hours, like any manual pick,
but the curator's own next plan still goes live). With mornings and
evenings on, it picks the half that should be showing right now in your
time zone. Returns the curator object with `templateIsActive: true`; when
it was already live nothing changes. No model call and no refresh budget.

Refused with `409`: `CURATOR_NO_TEMPLATE` before the first plan has landed
(or when the curator's template was deleted; its next plan makes a new
one), `CURATOR_DISABLED` when the curator is off,
`CURATOR_ACTIVATE_ACCOUNT_ONLY` with `?profile=` (a profile's curator takes
over when the profile is switched in), `CURATOR_PROFILE_LANE_ACTIVE` while a
switched-in profile's own curator holds the homepage, and
`TEMPLATE_GROUPS_NOT_SUBSET` when the curator's template includes a group
you have since left (refresh the curator to rebuild it).

#### `POST /curator/refresh`

Re-plans now instead of waiting for the cadence. The response is `202` and
arrives in a second or two; the work itself (a model call, group creation,
smart-list syncs) runs for minutes afterwards. Poll `GET /curator` and
watch `refreshing` to see when it lands.

A curator that is switched off (`enabled: false`) can't be refreshed:
`409 CURATOR_DISABLED`, and nothing is spent. Besides each curator's own
allowance (`refreshLimit`), manual refreshes **summed over the account and
all its profiles** are capped at 10 a day with Supporter, otherwise 4 a week
(server defaults); past that every curator on the account answers
`429 CURATOR_ACCOUNT_REFRESH_LIMIT`, and `refreshRemainingToday` already
counts it.

#### `POST /curator/feedback`

```json
{ "kind": "smart_list", "rowId": 17, "feedback": "pinned" }
{ "kind": "group", "groupId": "10862192", "feedback": "never" }
{ "kind": "smart_list", "rowId": 17, "feedback": "clear" }
{ "kind": "group", "groupId": "3d589d88", "feedback": "pinned", "placement": "bottom" }
```

| `feedback` | What it does |
|------------|--------------|
| `pinned`   | Keeps the row through every future re-plan. A pinned smart list is reused as-is, never recompiled. A pinned **group** the curator did not place itself goes at the **top** of the homepage, oldest keep first. This is "Always keep". A free account can keep at most **9** groups (Supporter: 13), which is `maxRows − minRows` of the plan's caps; one more is `409 CURATOR_PIN_LIMIT` with the `limit` |
| `never`    | Records the veto (the model is told not to propose it again) and takes the row off the homepage. For a `smart_list` row that is **destructive and immediate**: the private group is deleted (history-safe: your watch history survives). For a `group` row the template is rewritten and the membership left only if the curator is what joined you; if the curator has no homepage built yet, the veto is still recorded and nothing is removed until the next run |
| `clear`    | Removes an earlier `pinned` or `never` mark |

`placement` (optional, only with `feedback: "pinned"`) says where a pinned
row lands:

| `placement` | Where |
|-------------|-------|
| `top`    | First on the homepage (right after Up Next), whatever the curator chose. Several `top` pins keep the order you pinned them in. **The default for a group** |
| `bottom` | Last, below even your recommendation rows |
| `auto`   | The curator places it; left out of a plan, it goes back where it was. **The default for a smart-list row** |

Omitted on a row that is already pinned, the placement is unchanged; omitted
on a new pin, it is the default. Re-sending `pinned` with a different
`placement` moves a kept group without counting as a new keep (it never hits
the `CURATOR_PIN_LIMIT`). Any other value (or a `placement` with `never` /
`clear`) is `400 CURATOR_BAD_PLACEMENT`. A pinned row in `plan.rows` carries
its effective `placement`.

The two `kind`s are gated differently, and the refusal is the same
`404 NOT_FOUND` for both:

- `kind: "smart_list"`: the `rowId` must be one of **your own live
  curator rows**. Somebody else's row, an already-removed one, or an id
  that is not a curator row at all is `404`. It does **not** have to still
  be in the current plan, so a row you can see is a row you can un-pin.
- `kind: "group"`: the `groupId` must be a group **you belong to** (the
  curator's own private shelves excepted; pin those as `smart_list` rows),
  **in your current plan**, or one you have already marked (so a `clear`
  keeps working after the group leaves the plan). Any other group id is
  `404`, whether or not it exists.

The response is the full curator object, so read `plan.rows` back rather
than assuming what changed. A `never` that arrives while a plan is being
applied is recorded at once, but the removal waits for that apply to finish:
the answer then carries `"pending": true` and `refreshing` is `true` until
the row is gone.

#### `GET /curator/groups`

```json
{ "data": { "groups": [
  { "id": "3d589d88", "name": "Korean cinema", "items": 64, "kind": "group", "pinned": true, "never": false, "placement": "top", "system": false },
  { "id": "a1b2c3d4", "name": "Up Next", "items": 6, "kind": "up_next", "pinned": false, "never": false, "placement": null, "system": true },
  { "id": "e5f6a7b8", "name": "My Recommendations", "items": 40, "kind": "recommendations", "pinned": false, "never": false, "placement": null, "system": true }
], "pinLimit": 9 } }
```

Every group you belong to except the curator's own private shelves, by name.
`kind` is `group`, `up_next`, `recommendations` or `because_you_watched`.
`pinned` means it is already on your "Always keep" list. Pin or clear it
with `POST /curator/feedback { "kind": "group", "groupId", "feedback" }`.
`never` means you vetoed it; clear that with `feedback: "clear"`.
`placement` is where a kept group lands (`top`, `bottom` or `auto`; see
`POST /curator/feedback`); `null` when it is not kept.
`system` rows are your account's own LIVE Up Next and recommendation rows
(Up Next while it is enabled; a recommendation row while that feature is on
and its list is active); they are governed by `keepSystemRows`, not by
pinning. `pinLimit` is how many groups you may keep.

#### `GET /curator/history`

```json
{ "data": { "plans": [
  { "id": 118, "headline": "Autumn, and the shows you left half-finished",
    "status": "applied", "createdAt": "2026-09-20T03:14:07Z", "model": "claude-sonnet-5",
    "counts": { "rows": 8, "added": 3, "retired": 2, "left": 0, "dropped": 1 } }
] } }
```

`status` is one of `applied` (the homepage changed and the template was
activated), `applied_deferred` (built, but a rule or a pause held the
activation back), `applied_dayparts` (Day and Night templates written; the
curator's own rules switch between them), `rejected` (too few rows survived validation, or an
upstream outage failed most of the new shelves, so the homepage was left
alone and the curator retries in a few hours), `skipped_unchanged` (nothing about you changed,
so no model call was made) and `failed` (a provider or budget error; see
`lastError`). Counts are zero for every status but the three applied ones,
which is the honest answer to "why did nothing change last Sunday?".

#### Errors

| Status | Code                     | Meaning |
|--------|--------------------------|---------|
| 404 | `CURATOR_DISABLED` | The deployment has the AI curator off (`AI_ENABLED` / `AI_CURATOR_ENABLED`, or AI switched off by the site owner). Every endpoint in this section, including the reads |
| 403 | `AI_BETA_ONLY`     | The curator is in a closed beta and the key owner isn't in it. Every endpoint in this section, including the reads; nothing was read or changed. Except a `PATCH` that only switches `enabled`/`dayparts` off, which is carried out |
| 400 | `INVALID_INPUT`    | Bad `cadence`, non-string `instructions`, over 500 characters, non-boolean `keepSystemRows` or `dayparts`, bad `kind`/`feedback`, or a non-integer `rowId` |
| 402 | `PLAN_REQUIRED`    | `PATCH` with `cadence: "daily"` (`error.details.feature` `curator_daily`) or `dayparts: true` (`curator_dayparts`) on a free account; nothing was saved |
| 404 | `NOT_FOUND`        | That row is not yours, or that group is neither one you belong to nor in your current plan |
| 409 | `CURATOR_BUSY`     | A run is already in flight; wait for `refreshing` to go false. A scheduled run counts, so this can happen on a first click |
| 409 | `CURATOR_DISABLED` | `POST /curator/refresh` or `POST /curator/activate` on a curator that is switched off. Nothing spent. (The `404` with this code is the whole feature being off; this `409` is only this curator's switch) |
| 409 | `CURATOR_NO_TEMPLATE` | `POST /curator/activate` before the curator's first plan has landed, or after its template was deleted |
| 409 | `CURATOR_ACTIVATE_ACCOUNT_ONLY` | `POST /curator/activate?profile=<id>`: only the account's curator can be switched in this way |
| 409 | `CURATOR_PROFILE_LANE_ACTIVE` | `POST /curator/activate` while a switched-in profile's own curator holds the homepage (`deferralReason: "profile_lane_active"`) |
| 409 | `TEMPLATE_GROUPS_NOT_SUBSET` | `POST /curator/activate` when the curator's template includes a group you have since left. Refresh the curator to rebuild it, then switch again |
| 409 | `CURATOR_LANE_LIMIT` | `PATCH /curator?profile=<id>` turning a profile's curator on while the most profiles allowed (8 by default) already have theirs on. `error.details.limit` is the cap; turn one off first. Nothing saved |
| 409 | `CURATOR_PIN_LIMIT` | `POST /curator/feedback` pinning one more group than the Always-keep cap allows. `error.details.limit` is the cap (9 free, 13 Supporter); clear a pin first |
| 429 | `CURATOR_REFRESH_LIMIT` | The manual-refresh cap: 3 a day with Supporter (`curator_daily`), otherwise 2 a week (server defaults). `error.details` is `{ refreshRemainingToday: 0, refreshWindow, refreshLimit }` |
| 429 | `CURATOR_ACCOUNT_REFRESH_LIMIT` | The account-wide cap: manual refreshes summed over the account's and every profile's curator, capped at 10 a day with Supporter, otherwise 4 a week (server defaults). `error.details` is the same as `CURATOR_REFRESH_LIMIT`'s plus `scope: "account"`, and `refreshLimit` is the account's cap |
| 500 | `CURATOR_REFRESH_FAILED` | The refresh could not be started at all. Distinct from `409`/`429`, which are refusals; this one is a fault worth reporting |
| 500 | `CURATOR_ACTIVATE_FAILED` | `POST /curator/activate` could not switch the homepage. A fault worth reporting |

---

### Starter packs

Starter packs are admin-curated bundles of groups a user can subscribe
to in one call, designed for onboarding. Subscribing creates (or
updates) a **pack-derived template** named after the pack and activates
it.

#### `POST /api/v1/starter-packs/:id/subscribe`
**Scopes:** `starter_packs:subscribe`
**Body:** none. `:id` is the pack's slug.

Side effects: joins you to the pack's public groups, creates/merges the
pack-derived template (with the pack's per-group display settings),
activates it (clearing any auto-switch pause), and pushes to Stremio
Link if connected. Re-subscribing is idempotent and merges any groups
added to the pack since.

**Returns** (note: camelCase keys):

```json
{
  "data": {
    "templateId": 15,
    "templateName": "Horror Essentials",
    "added": 4,
    "alreadyMember": 1,
    "left": 0,
    "skipped": [],
    "errors": []
  }
}
```

**Errors:** `404 STARTER_PACK_NOT_FOUND`,
`400 STARTER_PACK_HAS_NO_GROUPS`,
`400 STARTER_PACK_HAS_NO_ELIGIBLE_GROUPS` (with `details.skipped`).

Listing available packs currently has no v1 endpoint; packs surface in
the web app's onboarding flow.

---

## Filter config reference

Used by `/discover/preview`, `/discover/execute`, and `smart_lists.filter_config`.

```ts
{
  // Core — required
  mediaType: "movie" | "tv" | "both",

  // Filters
  genres:               number[],           // TMDB genre IDs; see GET /discover/genres
  withoutGenres:        number[],
  yearRange:            { from?: number; to?: number },
  runtimeRange:         { min?: number; max?: number },   // movies only
  tmdbRating:           { min?: number; max?: number },   // 0–10
  voteCountMin:         number,
  imdbRating:           { min?: number; max?: number },   // post-filter, see note
  malRating:            { min?: number; max?: number },   // anime mode only
  originalLanguage:     string[],           // ISO 639-1: "en", "ja", ...

  // Exact date windows (absolute YYYY-MM-DD; finer-grained than yearRange)
  releaseDateGte:       string,             // movies: primary release date >=
  releaseDateLte:       string,             // movies: primary release date <=
  airDateGte:           string,             // tv: first air date >=
  airDateLte:           string,             // tv: first air date <=

  // Rolling window (relative; re-resolved on every sync, so it never goes stale)
  releaseWindowDays:    number,             // 1-3650. movies: primary release date within the last N days;
                                            // tv: ANY episode aired within the last N days (so returning
                                            // seasons qualify). Overrides yearRange/releaseDateGte/airDateGte.
                                            // Prefer this over a hard-coded yearRange for "new on X" lists.

  // Watch providers
  watchProviders:       number[],
  watchRegion:          "US" | "GB" | ...,
  monetizationType:     ("flatrate" | "rent" | "buy" | "free" | "ads")[],

  // People, keywords, networks, companies
  withCast:             number[],           // TMDB person IDs
  withCrew:             number[],
  withKeywords:         number[],           // NOTE: multiple keywords are ANDed
  withoutKeywords:      number[],
  withNetworks:         number[],           // TV only
  withCompanies:        number[],           // TMDB company IDs; see GET /discover/companies
  withoutCompanies:     number[],

  // Certifications (movies)
  certifications:       string[],           // "PG-13", "R"
  certificationCountry: "US",

  // Release type (movies)
  releaseTypes:         number[],           // 1=Premiere 2=Limited 3=Theatrical 4=Digital 5=Physical 6=TV

  // Advanced
  productionCountries:  string[],           // ISO 3166-1
  sortBy:               string,             // see below
  excludeUncategorized: boolean,

  // Anime mode (AniList) — see below
  anilistStudios:       string[],
  anilistSource:        string,
  anilistTags:          string[],
  anilistSeason:        string,
  anilistSeasonYear:    number,
  anilistStatus:        string
}
```

Array fields are lenient: a bare scalar (`"originalLanguage": "en"`) is
coerced to a one-element array server-side, but sending proper arrays
is preferred.

### `sortBy` values

Standard TMDB sorts: `popularity.desc` (default), `popularity.asc`,
`vote_average.desc`, `vote_average.asc`, `primary_release_date.desc`,
`primary_release_date.asc`, `revenue.desc`, `vote_count.desc`.

Special values handled by Stremshare:

| Value              | Behavior |
|--------------------|----------|
| `trending`         | Fetches by popularity, then re-sorts by 7-day *rising popularity delta*: "what's blowing up" rather than "what's big" |
| `currently_airing` | TV only: shows currently on the air |
| `airing_today`     | TV only: shows airing today |

### `imdbRating` note

IMDB ratings aren't a TMDB filter. This is applied server-side after
the TMDB fetch, using a locally maintained IMDB ratings dataset. Titles
with no known IMDB rating are excluded when the filter is set, so a
strict `imdbRating.min` on niche content can shrink results more than
expected.

### Anime mode (AniList)

A filter config is routed to **AniList** instead of TMDB when either:

- `originalLanguage` includes `"ja"` **and** `genres` includes `16`
  (Animation), or
- any `anilist*` field is set.

In anime mode the `anilistStudios`, `anilistSource` (e.g. `MANGA`,
`LIGHT_NOVEL`, `ORIGINAL`), `anilistTags`, `anilistSeason`
(`WINTER`/`SPRING`/`SUMMER`/`FALL`), `anilistSeasonYear`, and
`anilistStatus` (e.g. `RELEASING`, `FINISHED`) fields become available,
and `malRating` filtering applies. `/discover/preview` responses
report `meta.source: "anilist"` so you can confirm which engine ran.

---

## Recipes

### Build a "Netflix" parent group with sub-themed smart lists

```bash
KEY="ss_live_..."
BASE="https://stremshare.com/api/v1"

# 1. Create the parent group
GROUP=$(curl -s -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Netflix","description":"Curated Netflix collections"}' \
  $BASE/groups | jq -r '.data.id')

# 2. Smart list: highly-rated dramas streaming on Netflix US
curl -s -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"name\": \"Netflix drama (7+)\",
    \"filter_config\": {
      \"mediaType\": \"movie\",
      \"genres\": [18],
      \"tmdbRating\": {\"min\": 7.0},
      \"voteCountMin\": 500,
      \"watchProviders\": [8],
      \"watchRegion\": \"US\",
      \"monetizationType\": [\"flatrate\"]
    },
    \"sync_frequency\": \"weekly\"
  }" \
  $BASE/groups/$GROUP/smart-lists

# 3. Sync it immediately (optional — it will also sync on schedule)
# (See response of step 2 for the smart list ID)
```

Find Netflix's provider ID with
`GET /api/v1/discover/providers?region=US&mediaType=movie`. Netflix is
usually `8`.

### Import a curated MDBList as a smart list

```bash
curl -H "Authorization: Bearer $KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "source": "mdblist",
       "url": "https://mdblist.com/lists/linaspurinis/top-watched-movies-of-the-week/",
       "create_smart_list": true,
       "sync_frequency": "daily",
       "smart_list_name": "Top watched (weekly refresh)"
     }' \
     $BASE/groups/$GROUP_ID/import
```

### Add a few hand-picked titles

```bash
# Resolve a title to an IMDB id first if you don't know it:
curl -s -H "Authorization: Bearer $KEY" \
  "$BASE/discover/search?q=the+godfather&mediaType=movie"

curl -H "Authorization: Bearer $KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "items": [
         {"contentId": "tt0111161"},
         {"contentId": "tt0068646"},
         {"contentId": "tt0468569"}
       ]
     }' \
     $BASE/groups/$GROUP_ID/content
```

### Enable a group in the addon and put it first

```bash
# Fetch current settings
CURRENT=$(curl -s -H "Authorization: Bearer $KEY" $BASE/addon-settings)

# Merge — PATCH is partial-safe
curl -X PATCH -H "Authorization: Bearer $KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "enabled_groups": ["<new-group-id>", "<existing-1>", "<existing-2>"],
       "group_order":    ["<new-group-id>", "<existing-1>", "<existing-2>"]
     }' \
     $BASE/addon-settings
```

### A Halloween addon that switches itself on

```bash
# 1. Save your current setup so you can come back to it
curl -s -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"name":"Everyday"}' $BASE/templates

# 2. Build a horror-only template (groups must be ones you're a member of)
TPL=$(curl -s -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"name":"Halloween","config":{"enabled_groups":["<horror-group-1>","<horror-group-2>"]}}' \
  $BASE/templates | jq -r '.data.id')

# 3. Auto-activate it from two weeks before Halloween through Nov 1
curl -s -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d "{
    \"template_id\": $TPL,
    \"rule_type\": \"holiday\",
    \"config\": { \"holiday_id\": \"us_halloween\", \"days_before\": 14, \"days_after\": 1 }
  }" \
  $BASE/template-rules

# 4. See when the next automatic switch will happen
curl -s -H "Authorization: Bearer $KEY" $BASE/template-rules/next-switch
```

When the window ends, the scheduler switches back to the
highest-priority matching rule's template. Add a low-priority
`day_of_week` rule covering all days to your "Everyday" template to
make the fallback explicit.

---

## Troubleshooting

### `401 NO_API_KEY`
Header missing or not `Bearer`. Expected: `Authorization: Bearer ss_live_...`.

### `401 INVALID_API_KEY`
Key doesn't exist, was revoked, or expired. Create a new one.

### `403 BETA_ONLY`
Your account isn't beta-enabled. The whole `/api/v1` surface is behind
the beta flag, so contact the site owner.

### `403 AI_BETA_ONLY`
The AI Curator is in a closed beta and your account isn't in it yet
(`GET /me` → `data.ai.curator` is `beta`). This is separate from the API
beta above; the site owner grants it.

### `403 INSUFFICIENT_SCOPE`
The response `details` block lists required vs. granted scopes. Edit
the key's scopes in the UI (Account → API Keys → the key's row) or
create a new key with more scopes.

### `403 NOT_MEMBER` / `NOT_CONTRIBUTOR`
Scope is fine, but the key's owner doesn't have the required group
role. Adjust membership via the web UI; roles can't be self-promoted
via the API.

### `429 RATE_LIMITED`
You hit the per-key limit (see [Rate limits](#rate-limits)). Wait the
number of seconds in the `Retry-After` header (also echoed as
`details.retry_after`) and retry. If you're only hitting it on
`/discover/preview`, `/discover/execute`, imports, or smart-list sync,
that's the stricter expensive-endpoint budget, so batch or space out
those calls.

### Imports silently return 0 items
- Verify the URL is a public list you can load in an incognito browser.
- For Trakt private lists, the user must have connected Trakt via the
  web UI first.
- IMDB lists can't be imported at all any more. See
  [IMDB imports are retired](#imdb-imports-are-retired).

### Smart list never auto-syncs
- Check `is_active`, `sync_frequency` (can't be `manual`), and
  `next_sync_at`. The sync scheduler runs every 15 minutes.
- `consecutive_failures` of 3+ pauses automatic syncs; trigger a
  manual sync to clear it.

### Smart list results shrink when adding `imdbRating`
Titles with no known IMDB rating are excluded once the filter is set;
see the [filter config note](#imdbrating-note).

### Template edits return `409 TEMPLATE_IS_PACK_DERIVED`
Templates created by starter-pack subscribe are managed by the pack.
`POST /templates/:id/duplicate` gives you an editable copy.

### How do I rotate a key?
There's no in-place rotation. Create a new key, update the caller,
revoke the old one in the UI.
