# Writing a plugin

A plugin is a folder with a `plugin.json` in it, dropped into `plugins/`
next to the app. It is loaded at startup — installing one never runs code
until the next boot, and a plugin that throws is marked broken and skipped
rather than taking the app down with it.

Plugins are local code running at the app's trust level. There is no
sandbox. That is a deliberate, documented limit: curation is the safety
model, not isolation.

```
plugins/my-pack/
  plugin.json
  plugin.py          # omitted for data-only plugins (themes)
  assets/            # optional, served at /plugin-assets/my-pack/
```

## plugin.json

```json
{
  "id": "my-pack",
  "name": "My Game Pack",
  "version": "1.0.0",
  "api_version": 1,
  "min_app_version": "0.1.0",
  "capabilities": ["game_adapter"],
  "entry": "plugin.py",
  "settings_schema": { "demo_dir": { "type": "path", "default": "" } },
  "author": "you",
  "homepage": "https://…"
}
```

`capabilities` is any of `game_adapter`, `media_provider`, `publisher`,
`theme`. `id` should match the folder name. `min_app_version` is checked
before the entry module is imported.

Settings declared in `settings_schema` become a preset domain,
`plugin-<id>`, readable from your module:

```python
from clipshelf import presets
cfg = presets.domain("plugin-my-pack").get("default")
```

There is no GUI for them yet — they are edited at
`DATA/presets/plugin-<id>/default.json` or through
`PUT /api/presets/plugin-<id>/default`. Read them behind a `try` so your
module still imports outside the app (its own tests, mostly).

---

## game_adapter

The most common kind: turn a game's events into marks on the recording's
timeline. **This is timestamping, not editing** — the product never cuts
automatically, so an adapter's only job is to say *what happened and when*.

```python
GAME = "mygame"                 # slug; must be unique
LABEL = "My Game"               # shown in the UI
PROCESS_NAMES = ["mygame.exe"]  # optional: adds to game detection

def parse(payload, state):
    """Called for each POST to /api/gsi/mygame?token=…

    Return a list of events. `state` is a dict that persists between calls
    for this game, so you can diff against the previous payload.
    """
    events = []
    if payload.get("round", {}).get("phase") == "over":
        events.append({
            "type": "round_end",        # any string; unknown types get a
            "title": "round over",      # neutral marker and a tooltip
            "detail": {"map": payload.get("map", {}).get("name", "")},
        })
    return events
```

Optional extras, all discovered by name:

| name | what it does |
|---|---|
| `CONFIG_FILENAME` / `config_text()` | a config file the user downloads and drops into the game, so it starts POSTing |
| `CONFIG_HINT` | one line telling them where to put it |
| `MARKER_STYLE` | `{event_type: {"color": "#f80", "icon": "💀", "label": "kill"}}` — how marks are drawn |
| `scan(path, t0, t1)` | a vision scanner: read frames from a recording between two timestamps and return the same event shape. For games with no telemetry. |
| `ASSETS_DIR` | folder served at `/plugin-assets/<id>/` |
| `POLL_URL` / `poll()` | for a game that must be ASKED rather than one that pushes — see below |

An adapter that raises is logged and counted, never fatal: ingest keeps
working and the other adapters are unaffected.

### Games that don't push

Some games never POST anywhere; they serve a local endpoint and expect to be
asked. League of Legends is the standard example — Riot's Live Client Data
API answers on `https://127.0.0.1:2999` for as long as a match is running.

Declare where to look and the app runs the loop for you:

```python
POLL_URL = "https://127.0.0.1:2999/liveclientdata/allgamedata"
POLL_INTERVAL_S = 1.0     # while it is answering
POLL_IDLE_S = 5.0         # while it is not (nobody is playing)
POLL_INSECURE = True      # its certificate is self-signed
POLL_RESET_AFTER_S = 60   # a longer gap means a different match
```

or define `poll()` yourself and return a payload (or `None` for "nothing
there right now") if the fetch is stranger than an HTTP GET. `POLL_URL` must
be a loopback address; the network is `poll()`'s business, not the app's.

Whatever comes back goes through the same `parse(payload, state)` and the
same writer as a pushed payload, with two differences worth knowing:

* **`state` survives a restart.** It is written to
  `DATA/poll_state/<game>.json`, so keep it JSON-serializable. This is what
  stops an app restart from re-reading a cumulative event log from the top
  and marking every play in it twice. Deciding whether the restored state
  still describes what is on screen is your adapter's job — you are the only
  one who knows what a new match looks like.
* **a closed port is not an error.** Nothing is reported to the user when
  the endpoint is not there; the game simply is not running.

### Marking something that already happened

An event may carry `offset_ms` — how long ago it happened, in milliseconds:

```python
events.append({"type": "kill", "title": "kill", "offset_ms": 3200,
               "detail": {}})
```

Without it a marker lands where the *poll* was rather than where the play
was, which for a once-a-second poll of a log that is already seconds old is
the difference between a clip that opens on the kill and one that opens
after it. Pushed adapters leave it out — their payload arrives at the moment
of the play. It works on the generic endpoint too, which is what an external
binder reporting something from a minute ago should use.

**No integration at all?** Anything can POST to `/api/gsi/<anything>` with
the install token and a `{"events": [...]}` body. That path needs no
plugin — it is the zero-code integration, and it is registered last so a
real adapter always wins.

## media_provider

Search and import media into the editor's bin.

```python
ID = "my-sounds"
KIND = "sound"                  # sound | gif | image | music

def search(q):
    return [{"name": "boom", "url": "https://…/boom.mp3", "thumb": None}]

def import_item(item):
    """Download it, return a local path."""
    return "/path/to/boom.mp3"

def preview(item):              # optional
    return item["url"]
```

Providers that need an API key should return an empty list when the key is
missing, rather than raising — the pane then shows its empty state.

## publisher

Somewhere a finished clip can go.

```python
ID = "my-host"
LABEL = "My Host"

def publish(clip_path, meta):
    return {"url": "https://…/abc", "id": "abc"}

def list_published():
    return [{"id": "abc", "url": "https://…/abc", "title": "…"}]

def delete(pid):
    ...
```

## theme

Data only — no `entry`, no Python.

```
plugins/my-theme/
  plugin.json      # "capabilities": ["theme"]
  theme.json       # { "id": "my-theme", "label": "My Theme", "dark": true }
  theme.css
```

The CSS redefines the token block. Copy `frontend/themes/liquid-dark.css`
and change the values; only tokens, no structural rules.

Renders are deliberately theme-independent — the render engine forces a
fixed theme, and a test asserts the same project renders identically under
every theme. A theme cannot change what an export looks like.

---

## Testing yours

Drop the folder in `plugins/` and restart. Settings → Plugins lists what
loaded, what it registered, and the traceback if it failed.

The app ships fixture plugins used by its own test suite —
`tests/fixtures/plugins/` has a working adapter, one that throws on import,
one that throws inside `parse`, and a data-only theme. They are the
shortest examples of each shape, and they exist to prove that a broken
plugin cannot take the app with it.
