# Configuration (config.json)

Use this as a reference for `config.json`.

Defaults and normalization come from `cw_platform/config_base.py`.

Need the raw, generated default JSON blocks? See [Default config values](/crosswatch/configuration-config-json/default-config-values.md).

{% hint style="warning" %}
`config.json` contains tokens and secrets.

Redact it before sharing logs or screenshots.
{% endhint %}

{% hint style="info" %}
Avoid editing `config.json` by hand.

Most settings are configurable in the UI.
{% endhint %}

### At a glance

| Topic          | Where                       | Notes                    |
| -------------- | --------------------------- | ------------------------ |
| Config file    | `<CONFIG_BASE>/config.json` | Created if missing.      |
| Docker default | `/config/config.json`       | Used when `/app` exists. |
| Manual edits   | Avoid                       | UI covers most settings. |
| Secrets        | Tokens and password hashes  | Redact before sharing.   |

### Config file location

CrossWatch chooses the config base directory in this order.

<table><thead><tr><th width="107.33331298828125">Priority</th><th>Condition</th><th>Base path</th></tr></thead><tbody><tr><td>1</td><td><code>CONFIG_BASE</code> env var is set</td><td><code>CONFIG_BASE</code> value</td></tr><tr><td>2</td><td>Running in Docker image (<code>/app</code> exists)</td><td><code>/config</code></td></tr><tr><td>3</td><td>Running from source</td><td>Project root (one level above <code>cw_platform/</code>)</td></tr></tbody></table>

The file path is always:

* `<CONFIG_BASE>/config.json`

### Load and save behavior

| Behavior                   | What it means                                                   |
| -------------------------- | --------------------------------------------------------------- |
| Defaults + deep merge      | Your config overlays `DEFAULT_CFG`. Missing keys are filled in. |
| Unknown keys preserved     | Extra keys are kept. Useful for experiments.                    |
| Version stamping           | `version` is rewritten on load/save.                            |
| Runtime normalization      | Some fields are auto-added, clamped, or coerced.                |
| Pair feature normalization | `pairs[].features.*` are coerced to a uniform shape.            |
| Atomic saves               | Writes use a temp file then replace `config.json`.              |
| Redaction helper           | Sensitive fields can be masked for UI/log output.               |

#### Defaults + deep merge

On startup, CrossWatch:

* Loads `config.json`.
* Deep-merges it over `DEFAULT_CFG`.

Merge rules:

* Dict + dict merges recursively.
* Any other type overrides the default.

#### Version stamping

On load/save:

* `version` = app version (leading `v` removed)

<table><thead><tr><th width="104.6666259765625">Priority</th><th>Source</th></tr></thead><tbody><tr><td>1</td><td><code>api.versionAPI.CURRENT_VERSION</code></td></tr><tr><td>2</td><td><code>APP_VERSION</code> env var</td></tr><tr><td>3</td><td>Fallback <code>v0.7.0</code></td></tr></tbody></table>

#### Runtime normalization (auto-added and clamped)

These behaviors happen during config load/save.

They can rewrite values in `config.json`.

**`security.webhook_ids`**

If missing or too short, IDs are auto-generated.

They use `token_urlsafe(24)`.

Keys:

* `security.webhook_ids.plextrakt`
* `security.webhook_ids.jellyfintrakt`
* `security.webhook_ids.embytrakt`
* `security.webhook_ids.plexwatcher`

**First-run UI marker**

On first run only, the loader adds:

```json
{ "ui": { "_autogen": true } }
```

**Normalized / clamped values**

* `scheduling.mode` is normalized to one of:
  * `disabled`
  * `hourly`
  * `every_n_hours`
  * `daily_time`
* `scheduling.every_n_hours` is clamped to `1..12`
* `scheduling.daily_time` falls back to `03:30` if invalid
* `ui.protocol` is forced to `http` or `https`
* `ui.tls.valid_days` is clamped to `1..3650`
* Provider `*.rate_limit.*` values are coerced to numbers.
  * Then clamped to a minimum of `0`.

#### Pair feature normalization

Each feature becomes:

```json
{ "enable": true, "add": true, "remove": false }
```

| Input          | Normalized result                                   |
| -------------- | --------------------------------------------------- |
| `true`         | Enabled, adds on, removes off                       |
| `false`        | Disabled (enable/add/remove all false)              |
| Missing keys   | Defaults: `enable:true`, `add:true`, `remove:false` |
| Invalid values | Feature disabled                                    |

Ratings has extra fields.

See [Ratings feature](#special-pairsfeaturesratings).

#### Redaction

`redact_config(cfg)` replaces sensitive values with `••••••••`.

| Path                          | Notes              |
| ----------------------------- | ------------------ |
| `app_auth.password.hash`      | Password hash      |
| `app_auth.password.salt`      | Password salt      |
| `app_auth.session.token_hash` | Session token hash |

### Key reference

#### Top-level

| Key                | Type   | Notes                               |
| ------------------ | ------ | ----------------------------------- |
| `version`          | string | Managed by the app.                 |
| `security`         | object | Runtime-generated webhook IDs.      |
| `pairs`            | array  | Pair definitions created in the UI. |
| `plex`, `trakt`, … | object | Provider connection and tuning.     |
| `sync`             | object | Global orchestrator defaults.       |
| `runtime`          | object | Logging and guardrails.             |
| `metadata`         | object | Metadata lookup settings (TMDb).    |
| `scrobble`         | object | Real-time progress forwarding.      |
| `scheduling`       | object | Periodic runs.                      |
| `ui`               | object | UI toggles.                         |
| `app_auth`         | object | Optional UI login.                  |

### Security (`security`)

This node stores webhook route IDs.

CrossWatch can auto-generate them.

| Key                                  | Type   | Notes                                    |
| ------------------------------------ | ------ | ---------------------------------------- |
| `security.webhook_ids.plextrakt`     | string | Legacy Plex → Trakt webhook path id.     |
| `security.webhook_ids.jellyfintrakt` | string | Legacy Jellyfin → Trakt webhook path id. |
| `security.webhook_ids.embytrakt`     | string | Legacy Emby → Trakt webhook path id.     |
| `security.webhook_ids.plexwatcher`   | string | Plex Watcher/webhook path id.            |

{% hint style="warning" %}
If you change these IDs, update your webhook URLs.

Restart CrossWatch after edits.
{% endhint %}

### Providers

| Provider                      | Stores                       | Used for                |
| ----------------------------- | ---------------------------- | ----------------------- |
| `plex`                        | server URL, token, tuning    | Sync + scrobble source  |
| `jellyfin` / `emby`           | server URL, token, tuning    | Sync + scrobble source  |
| `trakt` / `simkl` / `mdblist` | OAuth/API keys               | Tracker targets/sources |
| `anilist`                     | OAuth/API keys               | Anime tracker           |
| `tmdb`                        | API key                      | Matching support        |
| `tmdb_sync`                   | API key + session            | TMDb account sync       |
| `tautulli`                    | URL + API key                | History import          |
| `crosswatch`                  | local paths + snapshot rules | Local backup provider   |

#### Provider profiles

Most providers can store multiple profiles under an `instances` map (config key name).

* The implicit profile id is `default`.
* Additional profile ids are named like `PLEX-P01`, `TRAKT-P02`, etc.

Profiles are used by:

* sync pairs (`source_instance`, `target_instance`)
* Watcher routes (`provider_instance`, `sink_instance`)

Guide: [Profiles](/crosswatch/profiles.md)

<details>

<summary><strong>Plex</strong></summary>

#### Connection

| Key                  | Type   | Notes                                       |
| -------------------- | ------ | ------------------------------------------- |
| `plex.server_url`    | string | Example: `http://192.168.1.10:32400`        |
| `plex.verify_ssl`    | bool   | Keep `false` for self-signed.               |
| `plex.account_token` | string | Set via UI PIN login.                       |
| `plex.client_id`     | string | Client identifier.                          |
| `plex.machine_id`    | string | Targets a specific server.                  |
| `plex.username`      | string | Plex Home profile name.                     |
| `plex.account_id`    | string | Server-local account ID.                    |
| `plex.home_pin`      | string | Optional Plex Home PIN.                     |
| `plex.timeout`       | number | Seconds.                                    |
| `plex.max_retries`   | int    | Retry budget.                               |
| `plex.fallback_GUID` | bool   | Discover fallback for missing GUID mapping. |

#### Scrobble filters

| Key                       | Type  | Notes                      |
| ------------------------- | ----- | -------------------------- |
| `plex.scrobble.libraries` | array | Empty means all libraries. |

#### History

| Key                                   | Type  | Notes                                                                   |
| ------------------------------------- | ----- | ----------------------------------------------------------------------- |
| `plex.history.libraries`              | array | Whitelist of **library GUIDs**. Empty = all.                            |
| `plex.history.include_marked_watched` | bool  | Include Plex “marked watched” state (add-only behavior).                |
| `plex.history_workers`                | int   | Parallel workers for history indexing. `12–16` is ideal on a local NAS. |

#### Ratings

| Key                      | Type  | Notes                                                                   |
| ------------------------ | ----- | ----------------------------------------------------------------------- |
| `plex.ratings.libraries` | array | Whitelist of **library GUIDs**. Empty = all.                            |
| `plex.rating_workers`    | int   | Parallel workers for ratings indexing. `12–16` is ideal on a local NAS. |

#### Watchlist (Discover-driven)

| Key                                 | Type  | Notes                                                                                     |
| ----------------------------------- | ----- | ----------------------------------------------------------------------------------------- |
| `plex.watchlist_allow_pms_fallback` | bool  | Allow PMS watchlist fallback when needed. Keep `false` for strict Discover-only behavior. |
| `plex.watchlist_page_size`          | int   | Discover page size (`100–200`). Higher is faster, but can trigger 504s.                   |
| `plex.watchlist_query_limit`        | int   | Max Discover search results per query (`10–25`).                                          |
| `plex.watchlist_write_delay_ms`     | int   | Optional pacing between Discover writes. Set `50–150` if you hit 429/5xx.                 |
| `plex.watchlist_title_query`        | bool  | Use title/slug tokens for Discover candidate fetching.                                    |
| `plex.watchlist_use_metadata_match` | bool  | Try PMS `/library/metadata/matches` first, then fallback to Discover.                     |
| `plex.watchlist_guid_priority`      | array | GUID resolution order (first match wins).                                                 |

</details>

<details>

<summary><strong>TMDb</strong></summary>

| Key            | Type   | Notes                                                  |
| -------------- | ------ | ------------------------------------------------------ |
| `tmdb.api_key` | string | TMDb API key used for metadata lookup and ID bridging. |

</details>

<details>

<summary><strong>TMDb (Sync)</strong></summary>

Used only when you connect **TMDb (Sync)** for watchlist/ratings syncing.

| Key                     | Type   | Notes                                   |
| ----------------------- | ------ | --------------------------------------- |
| `tmdb_sync.api_key`     | string | TMDb v3 API key.                        |
| `tmdb_sync.session_id`  | string | User session ID from the connect flow.  |
| `tmdb_sync.account_id`  | string | Optional. Auto-discovered if missing.   |
| `tmdb_sync.timeout`     | number | HTTP timeout in seconds.                |
| `tmdb_sync.max_retries` | int    | Retry budget for transient HTTP errors. |
| `tmdb_sync.debug`       | bool   | Enables extra TMDb sync debug logging.  |

</details>

<details>

<summary><strong>Trakt</strong></summary>

#### OAuth

| Key                     | Type   | Notes                                   |
| ----------------------- | ------ | --------------------------------------- |
| `trakt.client_id`       | string | From your Trakt app.                    |
| `trakt.client_secret`   | string | From your Trakt app.                    |
| `trakt.access_token`    | string | OAuth2 access token.                    |
| `trakt.refresh_token`   | string | OAuth2 refresh token.                   |
| `trakt.scope`           | string | Usually `public` or `private`.          |
| `trakt.token_type`      | string | Usually `Bearer`.                       |
| `trakt.expires_at`      | int    | Epoch seconds.                          |
| `trakt._pending_device` | object | Temporary device code state (PIN flow). |

#### HTTP

| Key                 | Type | Notes                           |
| ------------------- | ---- | ------------------------------- |
| `trakt.timeout`     | int  | HTTP timeout (seconds).         |
| `trakt.max_retries` | int  | Retry budget (429/5xx backoff). |

#### Rate limiting

| Key                             | Type   | Notes                 |
| ------------------------------- | ------ | --------------------- |
| `trakt.rate_limit.get_per_sec`  | number | GET requests / second |
| `trakt.rate_limit.post_per_sec` | number | Writes / second       |

Rate-limit defaults and tuning: [Provider rate limiting](/crosswatch/provider-rate-limiting.md)

#### Watchlist

| Key                                | Type | Notes                                                        |
| ---------------------------------- | ---- | ------------------------------------------------------------ |
| `trakt.watchlist_use_etag`         | bool | Use ETag + local shadow to skip unchanged lists.             |
| `trakt.watchlist_shadow_ttl_hours` | int  | Refresh ETag baseline periodically even if 304s keep coming. |
| `trakt.watchlist_batch_size`       | int  | Chunk size for add/remove to reduce rate spikes.             |
| `trakt.watchlist_log_rate_limits`  | bool | Log X-RateLimit-\* and Retry-After when present.             |
| `trakt.watchlist_freeze_details`   | bool | Persist last status & ids for debugging.                     |

#### Ratings

| Key                        | Type | Notes                                    |
| -------------------------- | ---- | ---------------------------------------- |
| `trakt.ratings_per_page`   | int  | Items per page when indexing (`10–100`). |
| `trakt.ratings_max_pages`  | int  | Safety cap per type.                     |
| `trakt.ratings_chunk_size` | int  | Batch size for POST/REMOVE.              |

#### History

| Key                             | Type | Notes                                               |
| ------------------------------- | ---- | --------------------------------------------------- |
| `trakt.history_per_page`        | int  | Max allowed by Trakt (`100`).                       |
| `trakt.history_max_pages`       | int  | Safety cap for large histories.                     |
| `trakt.history_unresolved`      | bool | Enable unresolved “freeze” files.                   |
| `trakt.history_number_fallback` | bool | Episode number fallback if episode IDs are missing. |
| `trakt.history_collection`      | bool | Mirror history adds into your Trakt Collection.     |

</details>

<details>

<summary><strong>SIMKL</strong></summary>

| Key                      | Type   | Notes                                   |
| ------------------------ | ------ | --------------------------------------- |
| `simkl.access_token`     | string | OAuth2 access token.                    |
| `simkl.refresh_token`    | string | OAuth2 refresh token.                   |
| `simkl.token_expires_at` | int    | Epoch seconds.                          |
| `simkl.client_id`        | string | From your SIMKL app.                    |
| `simkl.client_secret`    | string | From your SIMKL app.                    |
| `simkl.date_from`        | string | `YYYY-MM-DD` (optional backfill start). |

#### Rate limiting

| Key                             | Type   | Notes                 |
| ------------------------------- | ------ | --------------------- |
| `simkl.rate_limit.get_per_sec`  | number | GET requests / second |
| `simkl.rate_limit.post_per_sec` | number | Writes / second       |

Rate-limit defaults and tuning: [Provider rate limiting](/crosswatch/provider-rate-limiting.md)

</details>

<details>

<summary><strong>AniList</strong></summary>

| Key                     | Type   | Notes                           |
| ----------------------- | ------ | ------------------------------- |
| `anilist.client_id`     | string | From your AniList app.          |
| `anilist.client_secret` | string | From your AniList app.          |
| `anilist.access_token`  | string | OAuth access token.             |
| `anilist.user`          | object | Cached viewer object (id/name). |

</details>

<details>

<summary><strong>MDBList</strong></summary>

#### Connection

| Key                   | Type   | Notes                   |
| --------------------- | ------ | ----------------------- |
| `mdblist.api_key`     | string | Your MDBList API key.   |
| `mdblist.timeout`     | int    | HTTP timeout (seconds). |
| `mdblist.max_retries` | int    | Retry budget.           |

#### Rate limiting

| Key                               | Type   | Notes                 |
| --------------------------------- | ------ | --------------------- |
| `mdblist.rate_limit.get_per_sec`  | number | GET requests / second |
| `mdblist.rate_limit.post_per_sec` | number | Writes / second       |

Rate-limit defaults and tuning: [Provider rate limiting](/crosswatch/provider-rate-limiting.md)

#### Watchlist

| Key                                  | Type | Notes                                         |
| ------------------------------------ | ---- | --------------------------------------------- |
| `mdblist.watchlist_shadow_ttl_hours` | int  | Shadow TTL (hours). `0` disables.             |
| `mdblist.watchlist_shadow_validate`  | bool | Validate shadow on every run.                 |
| `mdblist.watchlist_page_size`        | int  | GET page size for `/watchlist/items`.         |
| `mdblist.watchlist_batch_size`       | int  | Batch size for add/remove writes.             |
| `mdblist.watchlist_freeze_details`   | bool | Store extra details for “not\_found” freezes. |

#### Ratings

| Key                              | Type   | Notes                                          |
| -------------------------------- | ------ | ---------------------------------------------- |
| `mdblist.ratings_per_page`       | int    | Items per page when indexing.                  |
| `mdblist.ratings_max_pages`      | int    | Max pages to fetch (safety cap).               |
| `mdblist.ratings_chunk_size`     | int    | Batch size for POST/REMOVE.                    |
| `mdblist.ratings_write_delay_ms` | int    | Optional pacing between writes.                |
| `mdblist.ratings_max_backoff_ms` | int    | Max backoff time for retries.                  |
| `mdblist.ratings_since`          | string | First-run baseline; watermark overrides after. |

#### History

| Key                              | Type   | Notes                                          |
| -------------------------------- | ------ | ---------------------------------------------- |
| `mdblist.history_per_page`       | int    | Items per page for watched delta.              |
| `mdblist.history_max_pages`      | int    | Max pages to fetch (safety cap).               |
| `mdblist.history_chunk_size`     | int    | Batch size for watched/unwatched writes.       |
| `mdblist.history_write_delay_ms` | int    | Optional pacing between writes.                |
| `mdblist.history_max_backoff_ms` | int    | Max backoff time for retries.                  |
| `mdblist.history_since`          | string | First-run baseline; watermark overrides after. |

</details>

<details>

<summary><strong>Tautulli</strong></summary>

#### Connection

| Key                    | Type   | Notes                       |
| ---------------------- | ------ | --------------------------- |
| `tautulli.server_url`  | string | Example: `http://host:8181` |
| `tautulli.api_key`     | string | Tautulli API key.           |
| `tautulli.verify_ssl`  | bool   | Verify TLS certificates.    |
| `tautulli.timeout`     | number | HTTP timeout (seconds).     |
| `tautulli.max_retries` | int    | Retry budget.               |

#### History import

| Key                          | Type   | Notes                       |
| ---------------------------- | ------ | --------------------------- |
| `tautulli.history.user_id`   | string | Optional user filter.       |
| `tautulli.history.per_page`  | int    | Tautulli history page size. |
| `tautulli.history.max_pages` | int    | Safety cap.                 |

</details>

<details>

<summary><strong>Jellyfin / Emby</strong></summary>

Same structure.

#### Connection

| Key              | Type   | Notes                                        |
| ---------------- | ------ | -------------------------------------------- |
| `*.server`       | string | Base URL.                                    |
| `*.access_token` | string | Auth token.                                  |
| `*.user_id`      | string | Active user.                                 |
| `*.device_id`    | string | Client device id.                            |
| `*.username`     | string | Optional login username.                     |
| `*.user`         | string | Optional display name (hydrated after auth). |
| `*.verify_ssl`   | bool   | TLS verification.                            |
| `*.timeout`      | number | Seconds.                                     |
| `*.max_retries`  | int    | Retry budget.                                |

#### Scrobble filters

| Key                    | Type  | Notes                                    |
| ---------------------- | ----- | ---------------------------------------- |
| `*.scrobble.libraries` | array | Whitelist of library GUIDs. Empty = all. |

#### Watchlist (emulated)

| Key                                    | Type   | Notes                                      |
| -------------------------------------- | ------ | ------------------------------------------ |
| `*.watchlist.mode`                     | string | `favorites` \| `playlist` \| `collections` |
| `*.watchlist.playlist_name`            | string | Used when mode is `playlist`.              |
| `*.watchlist.watchlist_query_limit`    | int    | Batch size.                                |
| `*.watchlist.watchlist_write_delay_ms` | int    | Delay between writes.                      |
| `*.watchlist.watchlist_guid_priority`  | array  | ID match order.                            |

#### History

| Key                                | Type  | Notes                                    |
| ---------------------------------- | ----- | ---------------------------------------- |
| `*.history.history_query_limit`    | int   | Batch size.                              |
| `*.history.history_write_delay_ms` | int   | Delay between writes.                    |
| `*.history.history_guid_priority`  | array | ID match order.                          |
| `*.history.libraries`              | array | Whitelist of library GUIDs. Empty = all. |

#### Ratings

| Key                             | Type  | Notes                                    |
| ------------------------------- | ----- | ---------------------------------------- |
| `*.ratings.ratings_query_limit` | int   | Ratings query limit (default `2000`).    |
| `*.ratings.libraries`           | array | Whitelist of library GUIDs. Empty = all. |

</details>

<details>

<summary><strong>CrossWatch (local provider)</strong></summary>

| Key                            | Type   | Notes                                         |
| ------------------------------ | ------ | --------------------------------------------- |
| `crosswatch.root_dir`          | string | Local provider folder.                        |
| `crosswatch.enabled`           | bool   | Enables provider.                             |
| `crosswatch.auto_snapshot`     | bool   | Snapshot before writes.                       |
| `crosswatch.retention_days`    | int    | `0` keeps forever.                            |
| `crosswatch.max_snapshots`     | int    | `0` unlimited.                                |
| `crosswatch.restore_watchlist` | string | `latest`, empty, or a specific snapshot stem. |
| `crosswatch.restore_history`   | string | `latest`, empty, or a specific snapshot stem. |
| `crosswatch.restore_ratings`   | string | `latest`, empty, or a specific snapshot stem. |

</details>

### Sync (`sync`)

Global defaults. Pair-level settings override these by design.

| Key                             | Type   | Notes                                                             |
| ------------------------------- | ------ | ----------------------------------------------------------------- |
| `sync.enable_add`               | bool   | Allow additions by default.                                       |
| `sync.enable_remove`            | bool   | Safer default: disabled unless you opt in.                        |
| `sync.one_way_remove_mode`      | string | One-way delete semantics: `source_deletes` (default) or `mirror`. |
| `sync.verify_after_write`       | bool   | When supported, re-check destination after writes.                |
| `sync.dry_run`                  | bool   | Plan and log only. No writes.                                     |
| `sync.drop_guard`               | bool   | Guard against empty/suspect snapshots.                            |
| `sync.allow_mass_delete`        | bool   | If `false`, block large delete plans.                             |
| `sync.tombstone_ttl_days`       | int    | How long “observed deletes” stay valid.                           |
| `sync.include_observed_deletes` | bool   | If `false`, skip processing observed deletes for the run.         |

#### One-way delete modes

This setting matters only for **one-way** pairs.

It is ignored for **two-way** sync.

It is also gated by remove toggles:

* Global: `sync.enable_remove`
* Pair feature: `pairs[].features.<feature>.remove`

If either is `false`, no deletes happen.

**`source_deletes` (default)**

Safest behavior.

CrossWatch removes items from the destination only when the item looks like a **real deletion on the source**.

That avoids deleting:

* destination-only items
* items you manually added on the destination

**`mirror`**

Strict mirroring.

CrossWatch removes anything present on the destination but missing on the source.

This is destructive.

**Example (Python-style)**

```python
"sync": {
    # Global write gates (pair/feature settings will override these by design):
    "enable_add": True,                             # Allow additions by default
    "enable_remove": False,                         # Safer default: do not remove items unless explicitly enabled
    "one_way_remove_mode": "source_deletes",        # "source_deletes" | "mirror" (mirror = destructive; use with care)
}
```

**Force mirroring (JSON)**

Set:

```json
"sync": {
  "one_way_remove_mode": "mirror"
}
```

{% hint style="warning" %}
`mirror` can delete destination-only items.

Use it only if you really want strict mirroring.
{% endhint %}

#### Two-way defaults (optional)

| Key                                  | Type   | Notes                                                 |
| ------------------------------------ | ------ | ----------------------------------------------------- |
| `sync.bidirectional.enabled`         | bool   | Default off. Pairs still decide final mode.           |
| `sync.bidirectional.mode`            | string | Placeholder default (usually `two-way`).              |
| `sync.bidirectional.source_of_truth` | string | Optional tie-breaker if you enforce strict authority. |

#### Blackbox (including flapper protection)

| Key                             | Type | Notes                                                      |
| ------------------------------- | ---- | ---------------------------------------------------------- |
| `sync.blackbox.enabled`         | bool | Turn off to fully disable blackbox logic.                  |
| `sync.blackbox.promote_after`   | int  | Promote after N consecutive unresolved/fail events.        |
| `sync.blackbox.unresolved_days` | int  | Minimum unresolved age before it counts (`0` = immediate). |
| `sync.blackbox.pair_scoped`     | bool | Track per pair to avoid blocking the same title elsewhere. |
| `sync.blackbox.cooldown_days`   | int  | Auto-prune after cooldown.                                 |
| `sync.blackbox.block_adds`      | bool | Block planned ADDs while blackboxed.                       |
| `sync.blackbox.block_removes`   | bool | Block planned REMOVEs while blackboxed.                    |

### Runtime (`runtime`)

| Key                                    | Type   | Notes                                                  |
| -------------------------------------- | ------ | ------------------------------------------------------ |
| `runtime.debug`                        | bool   | Extra verbose logging (debug level).                   |
| `runtime.debug_http`                   | bool   | Extra verbose HTTP logging.                            |
| `runtime.debug_mods`                   | bool   | Extra verbose MOD logs for Synchronization Providers.  |
| `runtime.state_dir`                    | string | Optional override for state dir. Avoid for containers. |
| `runtime.telemetry.enabled`            | bool   | Usage stats.                                           |
| `runtime.snapshot_ttl_sec`             | int    | Reuse snapshots within this window.                    |
| `runtime.apply_chunk_size`             | int    | Batch size for apply.                                  |
| `runtime.apply_chunk_pause_ms`         | int    | Pause between chunks.                                  |
| `runtime.apply_chunk_size_by_provider` | object | Per-provider overrides (example: `SIMKL`).             |
| `runtime.suspect_min_prev`             | int    | Minimum previous size to enable suspect guard.         |
| `runtime.suspect_shrink_ratio`         | number | Shrink ratio to trigger suspect guard.                 |

### Metadata (`metadata`)

| Key                  | Type   | Notes                      |
| -------------------- | ------ | -------------------------- |
| `metadata.locale`    | string | Example: `en-US`.          |
| `metadata.ttl_hours` | int    | Coarse resolver cache TTL. |

### Scrobble (`scrobble`)

| Key                          | Type   | Notes                                                            |
| ---------------------------- | ------ | ---------------------------------------------------------------- |
| `scrobble.enabled`           | bool   | Master toggle.                                                   |
| `scrobble.mode`              | string | `watch` or `webhook`.                                            |
| `scrobble.delete_plex`       | bool   | Legacy name but still valid. Auto-remove movies from watchlists. |
| `scrobble.delete_plex_types` | array  | Legacy name but still valid. Types: `movie`, `show`, `episode`.  |

#### Watcher mode (`scrobble.watch`)

| Key                                         | Type   | Notes                                          |
| ------------------------------------------- | ------ | ---------------------------------------------- |
| `scrobble.watch.autostart`                  | bool   | Start watcher on boot if enabled + mode=watch. |
| `scrobble.watch.routes`                     | array  | Routes table (recommended).                    |
| `scrobble.watch.provider`                   | string | Legacy fallback. Prefer routes.                |
| `scrobble.watch.sink`                       | string | Legacy fallback. Prefer routes.                |
| `scrobble.watch.plex_simkl_ratings`         | bool   | Forward Plex ratings to SIMKL.                 |
| `scrobble.watch.plex_trakt_ratings`         | bool   | Forward Plex ratings to Trakt.                 |
| `scrobble.watch.plex_mdblist_ratings`       | bool   | Forward Plex ratings to MDBList.               |
| `scrobble.watch.pause_debounce_seconds`     | int    | Ignore micro-pauses just after start.          |
| `scrobble.watch.suppress_start_at`          | int    | Kill near-end start flaps (credits).           |
| `scrobble.watch.filters.username_whitelist` | array  | `["name", "id:123", "uuid:abcd…"]`             |
| `scrobble.watch.filters.server_uuid`        | string | Restrict to a specific server (Plex).          |

Route shape (each entry in `scrobble.watch.routes[]`):

As of **v0.9.15**, configure routes manually.

Legacy Watcher fields are not auto-converted anymore.

```json
{
  "id": "R1",
  "enabled": true,
  "provider": "plex",
  "provider_instance": "default",
  "sink": "trakt",
  "sink_instance": "TRAKT-P01",
  "filters": {}
}
```

#### Webhook mode (`scrobble.webhook`)

| Key                                                | Type   | Notes                                                      |
| -------------------------------------------------- | ------ | ---------------------------------------------------------- |
| `scrobble.webhook.pause_debounce_seconds`          | int    | Ignore micro-pauses.                                       |
| `scrobble.webhook.suppress_start_at`               | int    | Suppress near-end start flaps.                             |
| `scrobble.webhook.suppress_autoplay_seconds`       | int    | Plex autoplay suppression window.                          |
| `scrobble.webhook.probe_session_progress`          | bool   | Probe Plex sessions to match item by ratingKey/sessionKey. |
| `scrobble.webhook.plex_trakt_ratings`              | bool   | Forward Plex ratings to Trakt in webhook mode.             |
| `scrobble.webhook.filters_plex.username_whitelist` | array  | Accepted Account.title values. Empty = allow all.          |
| `scrobble.webhook.filters_plex.server_uuid`        | string | Restrict to a specific Plex server.                        |

#### Progress rules (`scrobble.trakt`)

Used by Trakt, SIMKL, and MDBList sinks.

| Key                                        | Type | Notes                          |
| ------------------------------------------ | ---- | ------------------------------ |
| `scrobble.trakt.progress_step`             | int  | Send progress in % steps.      |
| `scrobble.trakt.stop_pause_threshold`      | int  | STOP below this becomes PAUSE. |
| `scrobble.trakt.force_stop_at`             | int  | STOP at/above this is forced.  |
| `scrobble.trakt.regress_tolerance_percent` | int  | Clamp small backwards jumps.   |

### Scheduling (`scheduling`)

| Key                        | Type   | Notes                                                  |
| -------------------------- | ------ | ------------------------------------------------------ |
| `scheduling.enabled`       | bool   | Master toggle for periodic runs.                       |
| `scheduling.mode`          | string | `disabled`, `hourly`, `every_n_hours`, `daily_time`.   |
| `scheduling.every_n_hours` | int    | When mode=`every_n_hours`, run every N hours (`1–12`). |
| `scheduling.daily_time`    | string | When mode=`daily_time`, run at `HH:MM` (24h).          |
| `scheduling.advanced`      | object | Ordered per-pair schedule (optional).                  |

#### Advanced scheduling (`scheduling.advanced`)

Use this only if you want strict control.

It runs jobs in order.

* `scheduling.advanced.enabled` (bool)
* `scheduling.advanced.jobs[]` (array)

Related: [Scheduling](/crosswatch/scheduling.md)

### UI (`ui`)

| Key                         | Type   | Notes                               |
| --------------------------- | ------ | ----------------------------------- |
| `ui.show_watchlist_preview` | bool   | Show Watchlist Preview on Main tab. |
| `ui.show_playingcard`       | bool   | Show Now Playing card on Main tab.  |
| `ui.show_AI`                | bool   | Show ASK AI (GitBook) in UI.        |
| `ui.protocol`               | string | `http` or `https`.                  |

#### TLS (`ui.tls`)

| Key                  | Type   | Notes                                                 |
| -------------------- | ------ | ----------------------------------------------------- |
| `ui.tls.self_signed` | bool   | Auto-generate a self-signed certificate when missing. |
| `ui.tls.hostname`    | string | Used for certificate CN/SAN.                          |
| `ui.tls.valid_days`  | int    | Certificate validity (days).                          |
| `ui.tls.cert_file`   | string | Optional override path to a PEM cert.                 |
| `ui.tls.key_file`    | string | Optional override path to a PEM key.                  |

### UI auth (`app_auth`)

| Key                 | Type   | Notes                  |
| ------------------- | ------ | ---------------------- |
| `app_auth.enabled`  | bool   | Enables login.         |
| `app_auth.username` | string | Required when enabled. |

### Pairs (`pairs[]`)

Each entry is one sync connection.

| Field             | Type   | Notes                                        |
| ----------------- | ------ | -------------------------------------------- |
| `id`              | string | Generated by UI/API.                         |
| `enabled`         | bool   | Master switch.                               |
| `source`          | string | Provider name.                               |
| `source_instance` | string | Provider profile id (defaults to `default`). |
| `target`          | string | Provider name.                               |
| `target_instance` | string | Provider profile id (defaults to `default`). |
| `mode`            | string | `one-way` or `two-way`.                      |
| `features`        | object | Per-feature rules.                           |

#### Feature format

| Field    | Type | Meaning                  |
| -------- | ---- | ------------------------ |
| `enable` | bool | Run feature at all.      |
| `add`    | bool | Allow adds to target.    |
| `remove` | bool | Allow removes on target. |

Example:

```json
{
  "pairs": [
    {
      "id": "pair_abc123",
      "enabled": true,
      "source": "PLEX",
      "source_instance": "default",
      "target": "SIMKL",
      "target_instance": "default",
      "mode": "two-way",
      "features": {
        "watchlist": { "enable": true, "add": true, "remove": false },
        "history":   { "enable": true, "add": true, "remove": false },
        "ratings":   { "enable": true, "add": true, "remove": false, "types": ["movies","shows"], "mode": "only_new", "from_date": "" }
      }
    }
  ]
}
```

#### Special: `pairs[].features.ratings`

| Key         | Type   | Notes                                                    |
| ----------- | ------ | -------------------------------------------------------- |
| `types`     | array  | `movies`, `shows`, `seasons`, `episodes`. `all` expands. |
| `mode`      | string | `only_new` or `from_date`.                               |
| `from_date` | string | Used only when `mode = "from_date"`.                     |


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://wiki.crosswatch.app/crosswatch/configuration-config-json.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
