-
Notifications
You must be signed in to change notification settings - Fork 2
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.
| 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. Registering it is all that is needed: GET /api/search/sources lists it and the WebUI fans every keyword out to that list, so your source gets its own result section, a chip under the search field and an on/off row in Settings → Sources. adult=True puts it behind the 18+ confirmation; enabled_key= reuses a settings key you already own; media_types= declares what your site publishes (see below). The site_id must match [a-z0-9][a-z0-9_-]{1,39}
|
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 and a row in the DNS test (both read the same _MONITOR_SITES entry) -- one call covers both. Pass enabled_setting_default=False if your enabled_setting_key is opt-in; it defaults to True (an installed module source is on) |
register_image_hosts |
web.routes.image_proxy |
Your own image CDN host(s)/domain(s) on the /api/img proxy allowlist -- required for any poster_url your module returns to actually load, since the proxy fetches from an allowlist only |
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.
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 |
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=Truerequires 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_keyyourself.
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.
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.
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_idis the id the module already gaveregister_thirdparty(). Keyed by it, the registration is dropped automatically byunregister_module()when the module is disabled or uninstalled, and the Modulmanager lists the capability assubtitle_source→ "subtitle source". -
source_idmust not collide with a built-in (RESERVED_SOURCE_IDS = {"opensubtitles"}) or with another module's source — both raise. -
fetchruns 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/.
register_search_source(
item_id, site_id, search_fn,
label=None, adult=False, enabled_key=None,
media_types=None, # e.g. ["movies"] or ["series"]
)media_types is a list of any of "movies", "series" and "adult". It is
optional; omitting it means both films and series, because a source left out
of a lookup is a source that silently does not work, whereas one asked about a
media type it does not have simply answers with nothing.
It is used wherever a lookup already knows what it is after and should not spend a request on a source that cannot have it. The Seerr requests page's "find streams" search is the current case: a movie request only asks movie sources, a series request only series sources. Declaring it therefore saves your site one request per such search — the general search box still asks every enabled source, since a keyword says nothing about what it is.
Server-side the built-in equivalents live in web/source_policy.py's
BUILTIN_SOURCE_MEDIA_TYPES, and GET /api/search/sources returns
media_types per source, so a client can make the same decision.
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) |
Module Manager → Module Settings | Normalised to "thirdparty" and stored, but nothing renders it |
"settings" |
Module Manager → Module Settings | Ignored (that page groups by host) |
"notifications" |
A tab of its own plus the Module Settings list | A built-in channel id or the bare default becomes module_<item_id>; a custom id is kept |
"monitoring" |
A tab of its own plus the Module Settings list | Same rule |
The Integrations page's Third Party tab no longer renders module cards at all — it is now only for the integrations MediaForge itself ships (Crunchyroll, Fernsehserien.de, ComicVine …). A module is installed, updated and removed in the Module Manager, so that is where it is configured; two pages editing the same values is two places to look and one to forget.
"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.
Every module card is listed on Module Manager → Module Settings, grouped by
host — for notifications/monitoring next to the card on their own page. Not
a copy: both places drive the same /api/settings/thirdparty/<id> API.
The Modulmanager's "Open module" button follows the same split: it links to
/module-settings?open=<id> for the integrations and settings hosts, and to
<page>?open=<id>#<tab> only for notifications/monitoring.
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.
Reuse the existing design vocabulary rather than shipping your own CSS:
-
Form controls (
forms.css):.chb-mainfor checkboxes,.togglefor 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): putdata-mf-multiselecton a.mf-multiselectroot 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 withdata-none-label/data-many-label/data-max-namesand listen formf-multiselect-change/mf-multiselect-closeon 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 — andwindow.mfSafeUrl()forhref/src. Do not write your own escaper. -
Polling (
mf_poll.js):window.mfPoll(fn, ms)instead ofsetInterval, so your timer pauses while the tab is hidden. -
The shared series/detail modal (
templates/shared_modals.html): a plain{% include "shared_modals.html" %}in your own template is enough — the Language and Hoster<option>s it needs (lang_labels,sto_lang_labels,supported_providers) now come from a global context processor, so no view kwargs and no route change. It used to be four routes that passed them and everybody else that did not, which rendered two empty dropdowns with no error anywhere.supported_providersis read per request, so a hoster your module adds throughregister_hoster()shows up without a restart.
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.
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
badge_label="{} jobs are waiting", # OPTIONAL, the badge's tooltip
badge_suffix="", # OPTIONAL, a unit ("%", "GB"), max 4 chars
badge_tone="info", # OPTIONAL: info | err | level | muted
admin_only=False,
icon="M3 6h18M3 12h18", # OPTIONAL, SVG path data for a 24x24 path
)Rules worth knowing:
-
A badge without a
badge_labelis a riddle. The label becomes the button's tooltip and its accessible name, with{}replaced by the count. The built-in System button shipped without one, and its "58" was read as a version number and as an error code before anyone worked out it counted failed downloads. Send it translated, likelabel. -
A badge is a kind of number, and
badge_tonesays which.info(default) is a to-do count,erris something wrong,levelis a percentage that turns amber at 90 and red at 95 (pair it withbadge_suffix="%"),mutedis a plain total. Without a suffix every badge reads as "how many things are waiting for me", which is wrong for a level -- the built-in Storage button had no badge at all until it could say "87 %" instead of "87". -
viewis lazy,badgeis 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,
percentis clamped to 0–100, andhrefmust be a site-relative path (/library); an absolute or protocol-relative URL is removed. At most 12 items and 6 stats per panel. -
actioninstead ofhreffor modals. A few things are not pages: the queue is a modalbase.htmlships everywhere, and there is no/queueroute."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=Trueis 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.
🇬🇧 English
Users
- Installation
- Getting Started
- Migration from AniWorld
- Configuration
- Web UI
- Download System
- Download History
- AutoSync
- Calendar
- Library
- Authentication
- Notifications
- Integrations
- SyncPlay
- Anime4K Upscaling
- Encoding
- Modules
- Theme Packs
- Backup
- Operations
- Audit Log
- Rules & Languages
- Docker
- Supported Sites
Developers
🇩🇪 Deutsch
Benutzer
- Installation
- Erste Schritte
- Umzug von AniWorld
- Konfiguration
- Web-UI
- Download-System
- Download-Verlauf
- AutoSync
- Kalender
- Bibliothek
- Authentifizierung
- Benachrichtigungen
- Integrationen
- SyncPlay
- Anime4K-Upscaling
- Encoding
- Module
- Theme-Pakete
- Backup
- Betrieb
- Audit-Log
- Regeln & Sprachen
- Docker
- Unterstützte Seiten
Entwickler