Skip to main content

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:

FieldMeaning
IDnames 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)
RequiresBridgea 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 Start in its own goroutine, its Deps.Log tagged 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​

  1. Its config row in internal/config/settings.go's fields (key modules.<id>.enabled, variable AUDIOSILO_MODULES_<ID>_ENABLED, setting modules.<id>), a field on ModulesConfig, and the key in config.example.yaml. Its own settings, if any, are setting <id>.<name>, key modules.<id>.<name>.
  2. Its entry in internal/modules/builtin, with RequiresBridge if it needs the Bridge, and its own package internal/modules/<id> for Routes and Start.
  3. Its console copy (settings.module.<id>.name, .blurb, .needs) in every locale; if it brings a settings page, that page in SETTINGS_PAGES and mapped to the module in MODULE_PAGES.
  4. If listeners see it, the flag on the player's Capabilities type and in its ModuleFlag, and its entry in PLAYER_MODULES.
  5. Docs: its flag in the capability flags table and the GET /server module 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 the admin/settings-modules.png screenshot.