Metadata-Version: 2.4
Name: discord-metadata
Version: 0.1.2
Summary: A Discord toolkit for building metadata browsers with embeds and button navigation.
Keywords: discord,metadata,embed,bot
Author: Gabriel Jung
Author-email: Gabriel Jung <gabriel.jung@protonmail.com>
License-Expression: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Chat
Classifier: Topic :: Software Development :: Libraries
Requires-Dist: discord-py>=2.7.0
Requires-Python: >=3.12
Project-URL: Homepage, https://github.com/gabriel-jung/discord-metadata
Project-URL: Repository, https://github.com/gabriel-jung/discord-metadata
Project-URL: Bug Tracker, https://github.com/gabriel-jung/discord-metadata/issues
Description-Content-Type: text/markdown

# discord-metadata

A Discord toolkit for building interactive metadata browsers with embeds and select-menu navigation.

Define your entities once and get Discord embeds with paginated sections, navigable links,
and browsable search results. The Discord counterpart of
[rich-metadata](https://github.com/gabriel-jung/rich-metadata).

## Install

Requires Python 3.12+.

```bash
pip install discord-metadata
# or
uv add discord-metadata
```

## Quick start

```python
from discord_metadata import (
    BaseNavigator,
    DisplayEngine,
    EntityDef,
    HeaderField,
    HeaderLink,
    MetadataBot,
    SectionDef,
    SummaryField,
    SyncAPI,
    TableColumn,
)

# 1. Define entities
movie_def = EntityDef(
    type_name="movie",
    summary=[
        SummaryField(key="title", bold=True),
        SummaryField(key="year", prefix="(", transform=lambda v: f"{v})"),
    ],
    header_fields=[
        HeaderField("Director", key="director"),
        HeaderField("Year", key="year"),
        HeaderField("Genre", key="genre"),
    ],
    sections=[
        SectionDef(
            "cast",
            navigable=True,
            columns=[
                TableColumn("Actor", "name"),
                TableColumn("Role", "role"),
            ],
        ),
        SectionDef("synopsis"),
    ],
    header_links=[
        HeaderLink("Director: {director}", "person", ref_key="director_id"),
    ],
    title_key="title",
    color=0xE5A00D,
    footer="Movie Database",
    url_key="url",
)

# 2. Create engine and register definitions
engine = DisplayEngine()
engine.register(movie_def)

# 3. Wire up APIs and create bot
navigator = BaseNavigator(
    engine,
    apis={"movie": SyncAPI(MovieAPI())},  # or pass async APIs directly
)

bot = MetadataBot(navigator)

# 4. Register slash commands
@bot.tree.command(name="movie", description="Search for a movie")
async def cmd_movie(interaction, query: str):
    await bot.navigator.search_and_navigate(interaction, query, ["movie"])

# 5. Run
bot.run_with_args("DISCORD_TOKEN")
```

See [`examples/movie_bot.py`](examples/movie_bot.py) for a complete working example.

## Concepts

### EntityDef

Declares how an entity type is rendered as a Discord embed:

| Field | Purpose |
|-------|---------|
| `type_name` | Matches the `_type` key in data dicts |
| `summary` | One-line representation for search results and select menus |
| `header_fields` | Key-value fields shown in the embed header |
| `sections` | Named content blocks (text, tables, navigable lists) |
| `header_links` | Buttons that navigate to related entities |
| `image_url_key` / `thumbnail_url_key` | Embed image or thumbnail |
| `color` / `footer` / `url_key` | Embed appearance |
| `auto_full` | Show all sections at once (skip section menu; navigable rows become read-only) |

### SectionDef

Each section can be:

- **Text** — a plain string value rendered in the embed description
- **Table** — a list of dicts rendered with `columns` (list of `TableColumn`)
- **Navigable** — table rows become selectable items in the select menu
- **Lazy** — fetched on demand via a `lazy_fetchers={(type, section_key): fn}` mapping passed to `BaseNavigator` (sync or async; sync receives the unwrapped API)
- **Numbered** — items prefixed with `1.`, `2.`, etc. (default: `True`)

### DisplayEngine

Registry of entity definitions. Call `engine.register(def1, def2, ...)` to add definitions.

### BaseNavigator

The interactive browser. Main entry points:

| Method | Description |
|--------|-------------|
| `search_and_navigate(interaction, query, types)` | Search APIs, show results, navigate on selection |
| `browse(interaction, fetch_page, title=...)` | Paginated browsing with a `fetch_page(start, count)` callable |
| `browse_sources(interaction, sources)` | Let the user pick between multiple browsable sources |
| `navigate_entity(interaction, entity)` | Display an entity directly |
| `navigate_results(interaction, results)` | Show a result list |

### SyncAPI

Wraps a synchronous API (with `.get(ref)` and `.search(query)` methods) for safe
use in async Discord handlers via `asyncio.to_thread`.

```python
apis = {"book": SyncAPI(BookAPI(client))}
```

Async APIs can be passed directly without wrapping.

### MetadataBot

A ready-made `discord.Client` subclass that handles:

- Command tree setup and guild sync
- `.env` loading via python-dotenv (optional)
- `--guild GUILD_ID` CLI argument for instant command sync during development
- Cleanup callback via `on_close`

```python
bot = MetadataBot(navigator, on_close=client.close)
bot.run_with_args("DISCORD_TOKEN")
```

## Navigator options

```python
BaseNavigator(
    engine,
    apis={...},
    entity_ref_key="url",       # key used to fetch entity details (default: "url")
    lazy_fetchers={             # optional: (entity_type, section_key) -> fn(api, entity)
        ("movie", "synopsis"): lambda api, entity: api.fetch_synopsis(entity["id"]),
    },
    ephemeral=False,            # whether responses are ephemeral
    placeholder="Browse...",    # select menu placeholder text
    search_kwargs={},           # extra kwargs passed to search methods
)
```

## Paginated browsing

For APIs that return paginated results (discover, recent, upcoming, etc.),
use `browse()` with a `fetch_page` callable:

```python
@bot.tree.command(name="recent")
async def cmd_recent(interaction):
    await interaction.response.defer()
    await navigator.browse(
        interaction,
        fetch_page=lambda start, count: api.fetch_page(start, count),
        title="Recent releases",
    )
```

`fetch_page(start, count)` must return `(results: list[dict], total: int)`.
It can be sync or async.

## Data format

Entity data is passed as plain dicts with a `_type` discriminator:

```python
{
    "_type": "movie",
    "title": "Blade Runner",
    "year": "1982",
    "director": "Ridley Scott",
    "cast": [
        {"_type": "person", "name": "Harrison Ford", "role": "Deckard"},
    ],
}
```

Keys prefixed with `_` (except `_type`) are internal and stripped from public output.

## Development

```bash
git clone https://github.com/gabriel-jung/discord-metadata.git
cd discord-metadata
uv sync
```

## License

MIT
