Skip to content

Module API

Domekologe edited this page Jul 31, 2026 · 3 revisions

Module API

🌐 English · Deutsch

Everything a module can register with MediaForge, in one place. The full reference — with a runnable example module per entry point — lives in .examples/thirdparties/README.md in the repository; this page is the map.

A module is a folder under web/thirdparties/<name>/ with a register(app) function. Everything below is called from there.

Registration functions

Function Module What it adds
register_thirdparty web.thirdparties.registry The module itself: menu entry, settings card, dashboard widget, extra_settings fields, enable toggle. See "Where a settings card lands" below
register_provider providers A content source (site) with its own series/season/episode classes
register_search_source search An additional source for the global search
register_home_feed_source home_feed Rows and a chip for that source on the new home page
register_home_panel home_panels A button in the home page's panel bar, with its own panel and an optional count badge
register_hoster extractors A hoster/extractor that resolves an embed URL to a stream
register_site_mirrors mirrors Mirror domains for a site, with automatic failover
register_cineinfo_source web.cineinfo.registry A metadata source next to TMDB
register_subtitle_source subtitle_sources An external subtitle source in the download path, next to the built-in OpenSubtitles lookup
register_monitor_site web.uptime_monitor A card on the UpTime dashboard, probed like the built-in sites
register_notification_channel web.thirdparties.registry A notification channel next to Telegram/Pushover/Discord/ntfy
register_event_hook web.thirdparties.registry A callback on app events (download finished, queue item failed, …)
register_background_worker web.thirdparties.registry A worker thread started and stopped with the module
register_backup_category web.backup Own data in the Full/Selective backup
register_sensitive_keys web.db Settings keys that are encrypted at rest
register_ui_pref_key web.db A per-account UI preference

register_restart_handler (web.restart) is not part of this API — it is core-internal and only called by web/app.py.

Helper functions

Not registrations — these are core services a module calls. Anything else in web/ that starts with an underscore is core-internal and may change without notice; if you find yourself importing one, ask for a public wrapper instead.

Function Module Purpose
lookup_media web.tmdb_cache Cached, rate-limited TMDB lookup for a title or IMDB ID
is_tmdb_configured web.tmdb_cache Whether a TMDB API key is configured at all
get_setting / set_setting web.db Read/write a single setting
get_json_setting / set_json_setting web.db Read/write a list- or dict-valued setting

TMDB metadata (lookup_media)

from ...web.tmdb_cache import lookup_media

info = lookup_media("Dark", media_type="tv", require_confident=True)
if info:
    tmdb_id = info["tmdb_id"]
    plot    = info["overview"]

Returns the metadata dict (tmdb_id, media_type, title, overview, genres, providers, fsk, vote_average, trailer_key, recommendations, raw_details), or None when TMDB is unconfigured, nothing was found, or the result was filtered out. There is no {"found": False} case to check.

  • media_type="movie" / "tv" requires that kind of result.
  • require_confident=True requires the returned title to actually match the one you asked for. TMDB's search answers nearly every query with something, so turn this on whenever you display the result rather than just using the ID.
  • The API key, the provider country and the UI language are resolved for you. Don't read cineinfo_tmdb_api_key yourself.

Results are cached for 24 h and rate-limited process-wide, so calling this in a loop is fine. It performs blocking network I/O — do bulk lookups in a register_background_worker, not in a request handler.

List/dict settings (get_json_setting / set_json_setting)

from ...web.db import get_json_setting, set_json_setting

rooms = get_json_setting("module:my_mod:rooms", [])
rooms.append(name)
set_json_setting("module:my_mod:rooms", rooms)

Don't hand-roll json.dumps/json.loads around set_setting. A missing, empty, invalid or wrong-shaped value is logged and returns your default, so a corrupt row reads as "unset" instead of raising inside a request. The default is copied, never shared, and non-ASCII is stored readably.

Each setting key is written on its own — set_setting is a single-key upsert, so saving one value can never clear another. There is deliberately no bulk "write all my settings" call.

Subtitle sources (register_subtitle_source)

The download path collects subtitles in three passes: yt-dlp's own renditions, the hoster's out-of-band player config, and — only for the languages still missing — an external lookup. OpenSubtitles.com is the built-in implementation of that third pass; register_subtitle_source lets a module put its own service (a private server, a fansub index, a paid API) into the same step.

from ....subtitle_sources import register_subtitle_source

def fetch(video_path, have_langs, meta):
    # have_langs: ISO 639-2/B tags the file already has -- never fetch these
    # again. meta: {"query", "season", "episode", "imdb_id", "tmdb_id"},
    # each possibly None. Return the sidecars written, named
    # <video stem>.<lang>.<ext>; the existing mux path picks them up.
    if "ger" in have_langs:
        return []
    return []

register_subtitle_source(MODULE_ID, "myservice", "My Subtitle Service", fetch)
  • item_id is the id the module already gave register_thirdparty(). Keyed by it, the registration is dropped automatically by unregister_module() when the module is disabled or uninstalled, and the Modulmanager lists the capability as subtitle_source → "subtitle source".
  • source_id must not collide with a built-in (RESERVED_SOURCE_IDS = {"opensubtitles"}) or with another module's source — both raise.
  • fetch runs in the queue worker between download and ffmpeg mux, so it holds up that one episode: a couple of HTTP requests with short timeouts, no more. It must not raise — exceptions are caught and logged, but a source that throws every time is dead weight.
  • Counterparts: unregister_subtitle_source(item_id), thirdparty_subtitle_source_ids(), iter_subtitle_sources().

Reference module: .examples/thirdparties/example_subtitle_source/.

Where a settings card lands

settings_host picks the page; the page picks the spot. settings_tab is a request the host may override, applied at registration time by registry._placed_tab(), so everything downstream sees the real placement:

settings_host Card ends up settings_tab
"integrations" (default) Always the Third Party tab Ignored, silently
"notifications" Always a tab of its own A built-in channel id or the bare default becomes module_<item_id>; a custom id is kept
"monitoring" Always a tab of its own Same rule
"settings" Module Manager → Module Settings Ignored (that page groups by host)

"What have my modules added to this page?" has one answer per page, and it stops being an answer as soon as a module can hide on the CineInfo tab or inside Telegram's panel. A module that wants its own tab picks a host that gives it one, not a tab id.

Independently of that, every module card is also listed on Module Manager → Module Settings, grouped by host. Not a copy: both places drive the same /api/settings/thirdparty/<id> API.

Secrets

A "secret" field, a key in MODULE_SENSITIVE_SETTINGS and anything passed to register_sensitive_keys() is encrypted in the database, and the generic settings API returns a mask (registry.SECRET_MASK) instead of the value — a PUT carrying the mask back means "unchanged". This holds for every key MediaForge knows to be sensitive, not just for fields declared as "secret", so a secret rendered as a plain text field is masked too. Inside the module nothing changes: get_setting() decrypts as always. If you render such a value on a page of your own, do the same — never put a stored secret into the HTML.

Frontend building blocks

Reuse the existing design vocabulary rather than shipping your own CSS:

  • Form controls (forms.css): .chb-main for checkboxes, .toggle for on/off switches, .mf-segmented, .mf-multiselect, .mf-chip.
  • Layout and content (mf_components.css, loaded globally): .mf-search, .mf-toolbar, .mf-poster-grid, .mf-timeline, .mf-progress, .mf-empty, .mf-pagination-bar.
  • Colours (variables.css): never hardcode a hex value. Status pills use the pair --success + --success-bg (same for --warning, --error, --info); both halves are defined per theme.
  • Multi-select (mf_multiselect.js, loaded on every page): put data-mf-multiselect on a .mf-multiselect root and open/close, the trigger's summary label, outside-click/Escape and the positioning (it stays unclipped inside scrolling containers) are handled for you — no init call, so markup rendered later by JS works too. Word the label with data-none-label / data-many-label / data-max-names and listen for mf-multiselect-change / mf-multiselect-close on the root (detail: {values, labels}, bubbling). Helpers: window.mfMultiSelect.values(), .labels(), .refresh(), .open(), .close(), .closeAll().
  • Escaping (mf_escape.js, loaded on every page): window.mfEscape() for anything that goes into HTML — it is quote-safe, so it covers attributes too — and window.mfSafeUrl() for href/src. Do not write your own escaper.
  • Polling (mf_poll.js): window.mfPoll(fn, ms) instead of setInterval, so your timer pauses while the tab is hidden.

Translations

Module templates use {{ _('...') }} like the core does; a module brings its own catalogue under <module>/translations/<locale>/LC_MESSAGES/. After editing a .po you must run pybabel compile — without it the change has no effect at runtime.

register_home_panel — a button on the home page

The home page has a row of buttons under the search field, and one panel below it whose content depends on the button (Queue, Activity, Library, and — for admins — Storage and System). A module adds its own:

from mediaforge.home_panels import register_home_panel

def my_panel():
    return {
        "stats": [{"label": "Waiting", "value": "3", "tone": "warn"}],
        "items": [{"title": "Something", "sub": "2 min ago",
                   "percent": 40, "href": "/mymodule", "tone": "ok"}],
        "link":  {"href": "/mymodule", "label": "Open"},
        "empty": "Nothing to do.",
    }

register_home_panel(
    item_id="mymodule",          # the id you passed to register_thirdparty()
    panel_id="mymodule",         # unique; the built-in ids are reserved
    label="My module",
    view=my_panel,               # called only when the user opens the panel
    badge=lambda: 3,             # OPTIONAL, runs on every home page visit
    admin_only=False,
    icon="M3 6h18M3 12h18",      # OPTIONAL, SVG path data for a 24x24 path
)

Rules worth knowing:

  • view is lazy, badge is not. The panel body is fetched when the user opens that panel and refreshed every 20 s while it is open (and while the tab is visible). The badge runs on every home page load for every registered panel, so it must be a cheap count — never a network call.
  • Text, never markup. Every string is escaped by the client. Unknown keys are dropped, percent is clamped to 0–100, and href must be a site-relative path (/library); an absolute or protocol-relative URL is removed. At most 12 items and 6 stats per panel.
  • action instead of href for modals. A few things are not pages: the queue is a modal base.html ships everywhere, and there is no /queue route. "action": "queue" opens the queue hub; the list of allowed actions is fixed (PANEL_ACTIONS), so a panel cannot name a JS function of its own.
  • Your strings are yours to translate. The built-in panels send i18n keys that the home page template resolves; a module sends finished text, because the core cannot translate a string it has never seen.
  • admin_only=True is enforced server-side, in the list and in the panel route — a non-admin gets 403, not just a hidden button.
  • A panel that raises does not break the page: the bar keeps working and the panel says it is unavailable. Same for a badge that raises (it counts as 0).
  • Cleanup is automatic through item_id — disabling the module removes the button.

The user's last open panel is stored on their account, so the home page comes back the way they left it.

Clone this wiki locally