Managed modules
A managed module is an optional feature that ships off. An admin turns it
on in the console's Settings > Modules, and it starts at once, with no restart;
nothing turns one on by itself (pairing a Bridge doesn't). A module may need a
Bridge feature to be usable, and then its capability flag
stays false until an online Bridge offers that feature.
The framework landed in Bridge Phase 2a with one module, contributions, as an
inert shell so the console had a real card. Phase 2b gave it its actions, which are
Bridge jobs rather than routes of its own (see
Jobs a module owns); it still has no routes and no
background work. Phase 2c-1 adds podcasts, requiring the podcast Bridge
feature, user actions and a scheduler. Phase 2c-2a extends it with episode
fetch/retention jobs and admin storage actions (see Podcasts).
Phase 2c-2b adds on-demand fetches, status/Retry and queue prefetch; player
views remain deferred. Phase 4a adds audible, requiring the audible Bridge
feature: admin routes behind the toggle, a scheduler, and one job kind
(audible.poll); Phase 4b a second, audible.import (backups), and the policy
pass after each poll; see Audible.
transcode, metadata and bridge
are not modules: their keys, variables and flags shipped before the framework, and
none is just a toggle (transcode follows ffmpeg, metadata needs its service built
at start as well as its switch, bridge follows the heartbeat).
What a module is (internal/modules)
A module is a modules.Module value:
| Field | Meaning |
|---|---|
ID | names the module everywhere: its capability flag on GET /server (capabilities.<id>), its config key modules.<id>.enabled, its console setting id modules.<id>, its variable AUDIOSILO_MODULES_<ID>_ENABLED, its routes' module_off gate and the console's copy (settings.module.<id>.name, .blurb, .needs) |
RequiresBridge | a Bridge feature (one of bridge.features on GET /server) the module needs, "" for none |
Routes(Registrar) | registers the module's HTTP routes; nil for none. Called once, when the API builds its handler |
Start(ctx, Deps) | the module's background work while it is on, returning when ctx is cancelled; nil for none. Deps carries a logger tagged with the module's id, the catalog and the Bridge service |
Beside it, Module.BridgeState(online, features) says where the module stands with
the feature it needs (BridgeOffline, BridgeNoFeature, BridgeReady, or "" for
a module that needs none), and modules.Active(on, state) is its capability flag;
GET /server and GET /admin/modules both work it out through them.
The registry is Modules() in internal/modules/builtin, in the order the console
lists them. It is a sub-package so a module's own package (internal/modules/<id>)
can import modules without a cycle. The launcher hands it to API.SetModules, which refuses to start
the server on a modules.Validate error: an id must match ^[a-z][a-z0-9_]{0,31}$,
appear once, not be a core capability's name (coreCapabilities), and have its
config row (config.HasModule).
The toggle and the capability flag
The toggle is an ordinary setting (see
Changing settings at runtime):
config key modules.<id>.enabled (false by default), setting modules.<id> in the
settings envelope's modules section, variable AUDIOSILO_MODULES_<ID>_ENABLED,
applied at once. A save is audited as settings.update, and a variable locks it
(409 setting_locked) like any other setting.
The capability flag (modules.Active, through the API's moduleActive) is the
toggle and the Bridge requirement met: for a module with a RequiresBridge, a Bridge whose last heartbeat is under two
minutes old and whose features hold it. It is false, never absent, while the
module is off, and it follows the Bridge's heartbeat, so it can change from one
request to the next (the server reuses a heartbeat for up to 5 seconds). GET /server reads the Bridge once per request and uses it for the bridge flag, the
bridge block and every module's flag.
The contributions module needs contribute, the Bridge's first feature that is not
a job family: see
The heartbeat and the capability.
Routes: always mounted, gated on the toggle
A module mounts its routes through the Registrar it is given: Admin(pattern, h)
puts h behind requireAdmin; User(pattern, h) behind requireAuth, then (Phase
2b) notKid (403 kid_account) and notDemo (403 demo_account), since a
module's actions (contributing to the community, first of all) are a grown-up's
own: the token's lookup loads is_demo and the kid flag, so neither reads the
account again. Inside those checks requireModule answers 404 module_off
while the toggle is off. The auth checks run first, so a caller they refuse gets
its 401 or 403, never module_off. A
pattern must be "METHOD /api/v1/...", so the global middleware and the auth
wrappers apply; anything else panics when the server starts (checkModulePattern).
The routes are mounted whether the module is on or off, so turning one on needs no
restart. They gate on the toggle only, so a route stays put while the flag follows
the heartbeat. An action that needs the Bridge refuses on its own, with its own
409, as the Bridge's job routes do.
Every api test's newTestEnv mounts the builtin modules through the real
Handler(), so a bad or clashing route pattern fails the tests, not a start.
The podcast actions are mounted in internal/api with the same user wrapper,
so builtin need not import api; the module's Routes remains nil. Only
follow/unfollow/check use this user gate. Admin retention PUT/DELETE and new
episode fetches use admin/not-demo auth and the same toggle plus the exact
Bridge kind and writable mapping; a valid present pin returns 200 before
those storage checks. Retention GET is also core and uses the admin catalog.
Authenticated podcast reads, including kids, are core with the client gate
and private-show scope still applied. This includes GET fetch status and ready
media. Missing media can automatically request a fetch; POST listener Retry
requires read scope and not-demo auth, allowing kids too. These routes do not
use the adult-only module wrapper. New intents check the module, exact online
Bridge kind and writable mapping in EnsurePodcastFetch; existing intents can
be returned/promoted and valid ready files reused. Demo streams cannot create
new intents. The core podcast_fetch:true capability advertises protocol
support independently of the live module flag. The
podcasts module needs podcast; contributions needs contribute; audible
needs audible.
The audible module mounts its console routes (GET /admin/audible/accounts,
GET /admin/audible/titles, POST /admin/audible/accounts/{account}/poll) with the
admin wrapper (moduleRoutes.Admin), as podcasts' admin storage routes are, and its
Bridge routes are not gated (the Bridge's page signs in either way).
DELETE /admin/audible/accounts/{account} is deliberately mounted outside the
toggle (requireAdmin only): it removes an account the Bridge can no longer
remove, which the scheduler would otherwise poll for good, so it must work after
the module is turned off.
Phase 2c-3's directory search (/podcasts/discovery/*) and OPML import use
the same adult user wrapper plus the client gate; the OPML export is core
(requireAuth + notDemo), so it works with the module off. Search needs no
Bridge, so it is advertised by the core podcast_discovery flag, not the
module flag (flags).
Turning the module off
(syncModules) or shutting down calls the discovery service's Reset: fetches
in flight are cancelled and every result, artwork and image cache emptied,
so turning it back on starts empty. See
Podcast discovery.
Jobs a module owns
A module's action can be a Bridge job instead of a route: bridge.kindSpec.module
names the module a kind needs and bridge.ModuleFor(kind, params) answers it. Such
a job gets the same toggle gate (404 module_off through writeModuleOff), and a
queued one is held while the toggle is off: see
Jobs of a managed module.
Background work
A modules.Runner (internal/modules/runner.go) runs each module's Start while
the module is on. The launcher calls the API's StartModules(ctx) once, beside the
chapter checks (Runner.Start: each module that is on starts), and defers
StopModules() (Runner.Stop), which cancels every run and waits for each to return,
so a module is done before the database closes. In between, handleUpdateSettings
calls syncModules (Runner.Sync) right after the live config swap of every save,
under the settings mutex:
- a module turned on starts
Startin its own goroutine, itsDeps.Logtagged with its id; a panic ends that run only, never the server; - a module turned off has its context cancelled, and the save doesn't wait for it to return;
- a module that is on but whose run has ended by itself (returned or panicked) starts again at the next save;
- a module turned back on while its last run is still stopping starts once that run has returned, so two runs of one module never overlap.
Nothing starts before StartModules or after StopModules.
The podcasts module's Start runs podcast.RunScheduler: it folds ended
subscribe/poll/fetch/retain outcomes and queues due polls, policy-selected
downloads and retention checks. It wakes at start, on committed job completion,
follow/policy changes and every 60 seconds, within per-pass limits described
in Podcasts. Off stops that work and holds
queued podcast jobs. Already claimed jobs may finish; committed playback and
core read/Bridge aggregate routes stay available. Fetch status reports
unavailable while the module is off, even for claimed/running work that may
finish; ready status wins. The one-ahead prefetch hook uses the same intent
checks and starts no new module-off work. See
on-demand play.
The audible module's Start runs audible.RunScheduler: a pass at once, then
every minute, queueing up to 20 due accounts' audible.poll at priority -10
while an online Bridge offers the kind (see
Audible), every hour for an account
with a released preorder it waits for. Off stops it, holds the queued polls and
backups, and skips the policy pass after a poll.
GET /api/v1/admin/modules
Admin. Every module in registry order, with its toggle, its flag and where it stands with the Bridge (shape in the reference).
The console: Settings > Modules
admin-ui's features/settings/modules-topic.tsx shows one card per module, over
GET /admin/modules. The server sends ids only: the name, blurb and needs line come
from the locale files (settings.module.<id>.*). The card's parts are described with
the rest of the console in The web UI.
A settings page a module brings is a SETTINGS_PAGES entry like any other (with its
icon in PAGE_ICONS and its case in the page's Topic), mapped to the module's id in
settings-model.ts's MODULE_PAGES. MODULE_PAGES adds no page: visiblePages
filters SETTINGS_PAGES through it, listing a module's page only while the module's
toggle is on, and a ?topic= for a hidden one opens General.
The player: src/modules/
The player keeps its own registry, PLAYER_MODULES in src/modules/registry.ts,
each entry's id being the module's capability flag. It is still empty after
Phase 2b (the player never contributes): the slots are open and tested with fake
modules, so the player renders exactly as before. See Player modules.
Adding a module
- Its config row in
internal/config/settings.go'sfields(keymodules.<id>.enabled, variableAUDIOSILO_MODULES_<ID>_ENABLED, settingmodules.<id>), a field onModulesConfig, and the key inconfig.example.yaml. Its own settings, if any, are setting<id>.<name>, keymodules.<id>.<name>. - Its entry in
internal/modules/builtin, withRequiresBridgeif it needs the Bridge, and its own packageinternal/modules/<id>forRoutesandStart. - Its console copy (
settings.module.<id>.name,.blurb,.needs) in every locale; if it brings a settings page, that page inSETTINGS_PAGESand mapped to the module inMODULE_PAGES. - If listeners see it, the flag on the player's
Capabilitiestype and in itsModuleFlag, and its entry inPLAYER_MODULES. - Docs: its flag in the capability flags
table and the
GET /servermodule table, its routes in the reference, its key in Configuration (the key, environment variable and runtime settings tables), its card in the User Guide's Modules, and theadmin/settings-modules.pngscreenshot.