How to Integrate Custom Metadata Adapters with RomM: A Complete Developer Guide

To integrate custom metadata adapters with RomM, you create a Python class that inherits from MetadataHandler in backend/handler/metadata/, implement the required is_enabled(), search(), and fetch() methods, register the module in __init__.py, and enable it via a configuration flag.

RomM enriches ROM files with metadata by delegating lookups to pluggable metadata adapters that live under backend/handler/metadata/. Each adapter inherits from the abstract base class MetadataHandler defined in backend/handler/metadata/base_handler.py, allowing the scan workflow in backend/watcher.py to discovery and execute them automatically. This modular architecture lets you integrate any third-party game database API without modifying core scan logic.

Understanding the Metadata Handler Architecture

The foundation of RomM's metadata system rests on the MetadataHandler abstract base class. Located in backend/handler/metadata/base_handler.py, this class defines the contract that every adapter must fulfill.

When backend/watcher.py initiates a library scan, it builds a mapping of enabled adapters using source_mapping = {handler: handler.is_enabled() for handler in METADATA_HANDLERS}. The watcher then calls each active handler's search and fetch methods, merging the returned JSON blobs into dedicated columns in the roms table (such as igdb_metadata, moby_metadata, or ss_metadata).

The base class provides critical utilities for adapter development:

  • normalize_search_term – Sanitizes ROM names for API queries
  • strip_sensitive_query_params – Removes API keys from logs
  • find_best_match – Fuzzy matches search results against file names

Creating a Custom Metadata Adapter

Implement the Handler Class

Create a new file in backend/handler/metadata/ (for example, myprovider_handler.py) that subclasses MetadataHandler. You must implement three core methods:

  1. is_enabled() – Returns True when the adapter should participate in scans, typically by reading a configuration flag from backend/config/config_manager.py
  2. search(self, term: str) – Queries the external API and returns a list of candidate dictionaries, each containing at least a name field
  3. fetch(self, game_id: str) – Retrieves full metadata for a selected game and returns a dictionary matching RomM's internal schema

Here is a minimal implementation:


# backend/handler/metadata/myprovider_handler.py

import httpx
from typing import Any, List

from handler.metadata.base_handler import MetadataHandler, METADATA_FIXTURES_DIR
from logger.logger import log


class MyProviderHandler(MetadataHandler):
    """Fetch game metadata from MyProvider API."""

    @classmethod
    def is_enabled(cls) -> bool:
        # Reads the flag from the config manager – see config_manager.py

        from config.config_manager import ConfigManager

        return ConfigManager.get_bool("ENABLE_MYPROVIDER", default=False)

    async def search(self, term: str) -> List[dict]:
        """Search MyProvider for games matching *term*."""
        normalized = self.normalize_search_term(term)
        url = f"https://api.myprovider.com/v1/search?q={normalized}"
        async with httpx.AsyncClient() as client:
            resp = await client.get(url, timeout=10.0)
            resp.raise_for_status()
            data = resp.json()
        # MyProvider returns a list of objects with ``title`` and ``id`` fields.

        return [{"name": g["title"], "id": g["id"]} for g in data.get("results", [])]

    async def fetch(self, game_id: str) -> dict:
        """Retrieve detailed metadata for *game_id*."""
        url = f"https://api.myprovider.com/v1/games/{game_id}"
        async with httpx.AsyncClient() as client:
            resp = await client.get(url, timeout=15.0)
            resp.raise_for_status()
            raw = resp.json()

        # Map MyProvider fields to RomM’s schema.

        return {
            "summary": raw.get("description"),
            "url_cover": raw.get("cover_url"),
            "url_screenshots": raw.get("screenshots", []),
            "release_year": raw.get("release_year"),
            # Any extra keys are stored as‑is in the JSON column ``myprovider_metadata``.

        }

    async def get_metadata(self, search_term: str) -> dict:
        """High‑level helper used by the scan process."""
        candidates = await self.search(search_term)
        best, score = await self.find_best_match(search_term, [c["name"] for c in candidates])
        if not best:
            log.debug("MyProvider: no match for %s", search_term)
            return {}
        game_id = next(c["id"] for c in candidates if c["name"] == best)
        return await self.fetch(game_id)

Register the Adapter

Add your handler to the package initializer so backend/watcher.py can discover it automatically:


# backend/handler/metadata/__init__.py

from .myprovider_handler import MyProviderHandler  # noqa: F401

The import pattern follows the convention from .myprovider_handler import MyProviderHandler, using the noqa: F401 comment to suppress linting warnings for unused imports since the registration happens via side-effect.

Enable via Configuration

Create a boolean flag in your environment configuration to control adapter activation:


# .env (or .env.example)

ENABLE_MYPROVIDER=true
MYPROVIDER_API_KEY=your_api_key_here

The is_enabled() method reads this via ConfigManager.get_bool("ENABLE_MYPROVIDER", default=False), ensuring the adapter only runs when explicitly enabled.

Wiring Into the Scan Pipeline

Once registered and enabled, your adapter integrates automatically into the metadata refresh process. The backend/watcher.py file gathers all enabled handlers and executes them during library scans.

When a scan triggers, the watcher:

  1. Collects enabled adapters via the source_mapping dictionary comprehension
  2. Iterates through each ROM file and calls the adapter's get_metadata method
  3. Merges returned dictionaries into the database (either into a dedicated JSON column or generic metadata fields)

Trigger a rescan to populate your new metadata:


# From the repository root

uv run python -m backend.watcher

Or invoke the API endpoint directly:

curl -X POST http://localhost:3000/api/tasks/scan-library -H "Authorization: Bearer <admin-token>"

Database Considerations for Custom Fields

If your provider supplies data that warrants dedicated columns rather than generic JSON storage, extend the Rom model in backend/models/rom.py with your new fields.

After modifying the model, generate an Alembic migration:

uv run alembic revision --autogenerate -m "add myprovider columns"

This creates a migration script in your alembic/versions directory. Apply it with uv run alembic upgrade head. The new columns will then be populated automatically during subsequent scans, accessible via the API at GET /api/roms/{id} or displayed in the frontend.

Summary

  • Create a handler module in backend/handler/metadata/ that subclasses MetadataHandler and implements is_enabled(), search(), and fetch()
  • Leverage base class utilities like normalize_search_term() and find_best_match() to handle fuzzy matching and data sanitization
  • Register the adapter by importing it in backend/handler/metadata/__init__.py so the watcher can discover it automatically
  • Enable via configuration by adding a boolean flag (e.g., ENABLE_MYPROVIDER) that is_enabled() reads from backend/config/config_manager.py
  • Extend the database schema if needed by modifying backend/models/rom.py and running Alembic migrations
  • Trigger scans using either the watcher CLI or the REST API to populate metadata immediately

Frequently Asked Questions

What methods must I implement in a custom metadata handler?

You must implement three methods: is_enabled() to return a boolean based on configuration flags, search(term: str) to return a list of candidate games with name fields, and fetch(game_id: str) to return a dictionary of metadata matching RomM's schema. The base class provides get_metadata() as a high-level wrapper that calls these automatically.

How does RomM decide which metadata adapter to use?

RomM uses the source_mapping dictionary in backend/watcher.py to filter handlers where is_enabled() returns True. Because you register adapters in backend/handler/metadata/__init__.py, they are automatically included in METADATA_HANDLERS and evaluated during each scan cycle without modifying the watcher code.

Can I store custom metadata in dedicated database columns?

Yes. If you need structured storage beyond the default JSON columns, modify the Rom model in backend/models/rom.py to add your fields, then generate and apply an Alembic migration using uv run alembic revision --autogenerate. The scan pipeline will populate these columns alongside the standard metadata fields.

How do I test my custom adapter without running a full library scan?

You can test your adapter's search() and fetch() methods directly by instantiating your handler class in a Python REPL or test script. For integration testing, trigger a targeted scan via the API endpoint POST /api/tasks/scan-library with specific parameters, or temporarily modify watcher.py to scan a single directory while debugging.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →