diff --git a/doc/meson.build b/doc/meson.build
index 4975917f4..c3fe1eea3 100644
--- a/doc/meson.build
+++ b/doc/meson.build
@@ -166,3 +166,33 @@ html_target = custom_target('pipewire-docs',
command: [ doxygen, doxyfile ],
install: true,
install_dir: docdir)
+
+
+if generate_module_manpages
+ module_man_rst_py = meson.project_source_root() / 'doc' / 'module-man-rst.py'
+ module_man_defines = []
+ foreach m : manpage_conf.keys()
+ if m != 'LIBPIPEWIRE_MODULES'
+ module_man_defines += ['-D', m, manpage_conf.get(m)]
+ endif
+ endforeach
+
+ foreach m : module_sources
+ name = m.split('.c').get(0)
+ file = 'libpipewire-' + name + '.7'
+
+ rst = custom_target(file + '.rst',
+ command : [python, module_man_rst_py, pandoc, name, '@INPUT@' ] + module_man_defines,
+ input : [ html_target ],
+ depend_files : [ module_man_rst_py ],
+ output : file + '.rst',
+ capture : true
+ )
+ custom_target(file,
+ output : file,
+ input : rst,
+ command : [rst2man, '@INPUT@', '@OUTPUT@'],
+ install : true,
+ install_dir : get_option('mandir') / 'man7')
+ endforeach
+endif
diff --git a/doc/module-man-rst.py b/doc/module-man-rst.py
new file mode 100644
index 000000000..0aa68c731
--- /dev/null
+++ b/doc/module-man-rst.py
@@ -0,0 +1,159 @@
+#!/usr/bin/python3
+"""
+Convert Doxygen HTML documentation for a PipeWire module to RST.
+"""
+import argparse
+import html, html.parser
+import re
+from pathlib import Path
+from subprocess import check_output
+
+TEMPLATE = """
+{name}
+{name_underline}
+
+{subtitle_underline}
+{subtitle}
+{subtitle_underline}
+
+:Manual section: 7
+:Manual group: PipeWire
+
+DESCRIPTION
+-----------
+
+{content}
+
+AUTHORS
+-------
+
+The PipeWire Developers <{PACKAGE_BUGREPORT}>;
+PipeWire is available from {PACKAGE_URL}
+
+SEE ALSO
+--------
+
+``pipewire(1)``,
+``pipewire.conf(5)``,
+``libpipewire-modules(7)``
+"""
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__.strip())
+ p.add_argument(
+ "-D",
+ "--define",
+ nargs=2,
+ action="append",
+ dest="define",
+ default=[],
+ )
+ p.add_argument("pandoc")
+ p.add_argument("module")
+ p.add_argument("htmldir", type=Path)
+ args = p.parse_args()
+
+ page = args.module.lower().replace("-", "_")
+ src = args.htmldir / f"page_{page}.html"
+
+ # Pick content block only
+ parser = DoxyParser()
+ with open(src, "r") as f:
+ parser.feed(f.read())
+ data = "".join(parser.content)
+
+ # Produce output
+ content = check_output(
+ [args.pandoc, "-f", "html", "-t", "rst"], input=data, encoding="utf-8"
+ )
+
+ if not content.strip():
+ content = "Undocumented."
+
+ name = f"libpipewire-{args.module}"
+ subtitle = "PipeWire module"
+
+ env = dict(
+ content=content,
+ name=name,
+ name_underline="#" * len(name),
+ subtitle=subtitle,
+ subtitle_underline="-" * len(subtitle),
+ )
+
+ for k, v in args.define:
+ env[k] = v
+
+ print(TEMPLATE.format(**env))
+
+
+def replace_pw_key(key):
+ key = key.lower().replace("_", ".")
+ if key in ("protocol", "access", "client.access") or key.startswith("sec."):
+ return f"pipewire.{key}"
+ return key
+
+
+class DoxyParser(html.parser.HTMLParser):
+ """
+ Capture div.textblock, and:
+ - Convert div.fragment to pre
+ - Convert a[@href="page_module_XXX.html"] to libpipewire-module-xxx(7)
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.content = []
+ self.stack = []
+
+ def feed(self, data):
+ try:
+ super().feed(data)
+ except EOFError:
+ pass
+
+ def handle_starttag(self, tag, attrs):
+ attrs = dict(attrs)
+
+ if self.stack:
+ if self.stack[-1] is None:
+ self.stack.append(None)
+ return
+
+ if tag == "div" and attrs.get("class") == "fragment":
+ tag = "pre"
+ attrs = dict()
+ elif tag == "a" and attrs.get("href").startswith("page_module_"):
+ module = attrs["href"].replace("page_module_", "libpipewire-module-")
+ module = module.replace(".html", "").replace("_", "-")
+ self.content.append(f"{module}(7)")
+ self.stack.append(None)
+ return
+
+ attrstr = " ".join(f'{k}="{html.escape(v)}"' for k, v in attrs.items())
+ self.content.append(f"<{tag} {attrstr}>")
+ self.stack.append(tag)
+ elif tag == "div" and attrs.get("class") == "textblock":
+ self.stack.append(tag)
+
+ def handle_endtag(self, tag):
+ if len(self.stack) == 1:
+ raise EOFError()
+ elif self.stack:
+ otag = self.stack.pop()
+ if otag is not None:
+ self.content.append(f"{otag}>")
+
+ def handle_data(self, data):
+ if self.stack and self.stack[-1] is not None:
+ if self.stack[-1] == "a":
+ m = re.match(r"^(PW|SPA)_KEY_([A-Z_]+)$", data)
+ if m:
+ data = replace_pw_key(m.group(2))
+
+ self.content.append(html.escape(data))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/man/libpipewire-modules.7.rst.in b/man/libpipewire-modules.7.rst.in
new file mode 100644
index 000000000..a8251d992
--- /dev/null
+++ b/man/libpipewire-modules.7.rst.in
@@ -0,0 +1,56 @@
+libpipewire-modules
+###################
+
+----------------
+PipeWire modules
+----------------
+
+:Manual section: 7
+:Manual group: PipeWire
+
+DESCRIPTION
+===========
+
+A PipeWire module is effectively a PipeWire client running inside
+``pipewire(1)`` which can host multiple modules. Usually modules are
+loaded when they are listed in the configuration files. For example
+the default configuration file loads several modules:
+
+::
+
+ context.modules = [
+ ...
+ # The native communication protocol.
+ { name = libpipewire-module-protocol-native }
+
+ # The profile module. Allows application to access profiler
+ # and performance data. It provides an interface that is used
+ # by pw-top and pw-profiler.
+ { name = libpipewire-module-profiler }
+
+ # Allows applications to create metadata objects. It creates
+ # a factory for Metadata objects.
+ { name = libpipewire-module-metadata }
+
+ # Creates a factory for making devices that run in the
+ # context of the PipeWire server.
+ { name = libpipewire-module-spa-device-factory }
+ ...
+ ]
+
+KNOWN MODULES
+=============
+
+- @LIBPIPEWIRE_MODULES@
+
+AUTHORS
+=======
+
+The PipeWire Developers <@PACKAGE_BUGREPORT@>; PipeWire is available from @PACKAGE_URL@
+
+SEE ALSO
+========
+
+``pipewire(1)``,
+``pipewire.conf(5)``,
+
diff --git a/man/meson.build b/man/meson.build
index a1fddb05b..79df3dca0 100644
--- a/man/meson.build
+++ b/man/meson.build
@@ -6,6 +6,14 @@ manpage_conf.set('PACKAGE_BUGREPORT', 'https://gitlab.freedesktop.org/pipewire/p
manpage_conf.set('PIPEWIRE_CONFIG_DIR', pipewire_configdir)
manpage_conf.set('PIPEWIRE_CONFDATADIR', pipewire_confdatadir)
+module_manpage_list = []
+foreach m : module_sources
+ name = m.split('.c').get(0)
+ module_manpage_list += ['``libpipewire-' + name + '(7)``']
+endforeach
+
+manpage_conf.set('LIBPIPEWIRE_MODULES', '\n- '.join(module_manpage_list))
+
manpages = [
'pipewire.1.rst.in',
'pipewire-pulse.1.rst.in',
@@ -22,6 +30,7 @@ manpages = [
'pw-mon.1.rst.in',
'pw-profiler.1.rst.in',
'pw-top.1.rst.in',
+ 'libpipewire-modules.7.rst.in',
]
if get_option('pipewire-jack').allowed()
diff --git a/man/pipewire.1.rst.in b/man/pipewire.1.rst.in
index 8b6383969..8f5aebd04 100644
--- a/man/pipewire.1.rst.in
+++ b/man/pipewire.1.rst.in
@@ -52,3 +52,4 @@ SEE ALSO
``pw-mon(1)``,
``pw-cat(1)``,
``pw-cli(1)``,
+``libpipewire-modules(7)``,
diff --git a/man/pipewire.conf.5.rst.in b/man/pipewire.conf.5.rst.in
index fb8144bd4..d5d39c258 100644
--- a/man/pipewire.conf.5.rst.in
+++ b/man/pipewire.conf.5.rst.in
@@ -110,3 +110,4 @@ SEE ALSO
``pipewire(1)``,
``pw-mon(1)``,
+``libpipewire-modules(7)``,
diff --git a/meson.build b/meson.build
index e21fca8b0..8c6552402 100644
--- a/meson.build
+++ b/meson.build
@@ -501,6 +501,15 @@ subdir('man')
doxygen = find_program('doxygen', required : get_option('docs'))
if doxygen.found()
+ generate_module_manpages = get_option('docs').enabled() and get_option('man').enabled()
+ if generate_manpages
+ pymod = import('python')
+ python = pymod.find_installation('python3', required: generate_module_manpages)
+ pandoc = find_program('pandoc', required: generate_module_manpages)
+ generate_module_manpages = python.found() and pandoc.found()
+ endif
+ summary({'Module manpage generation': generate_module_manpages}, bool_yn: true)
+
subdir('doc')
endif