-
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 |
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.
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/.
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.
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.
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
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. -
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
- 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
- Docker
- Unterstützte Seiten
Entwickler