|
@@ -112,33 +112,93 @@ class Provisioner:
|
|
|
|
|
|
|
|
Unloads in reverse creation order (loopbacks, then remap, then null-sink).
|
|
Unloads in reverse creation order (loopbacks, then remap, then null-sink).
|
|
|
Ignores modules that are already gone. Always clears the state file.
|
|
Ignores modules that are already gone. Always clears the state file.
|
|
|
|
|
+ Falls back to name-based discovery when no state file exists so
|
|
|
|
|
+ ``clear --yes`` can clean orphans (e.g. after reboot or manual delete).
|
|
|
"""
|
|
"""
|
|
|
state = self.store.load()
|
|
state = self.store.load()
|
|
|
- if not state:
|
|
|
|
|
- self.store.clear()
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ if state:
|
|
|
|
|
+ # Unload loopbacks in reverse order
|
|
|
|
|
+ for loopback in reversed(state.loopbacks):
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.pa.unload_module(loopback.module_id)
|
|
|
|
|
+ except RuntimeError:
|
|
|
|
|
+ pass # Already gone is fine
|
|
|
|
|
+
|
|
|
|
|
+ # Unload remap source
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.pa.unload_module(state.remap_module)
|
|
|
|
|
+ except RuntimeError:
|
|
|
|
|
+ pass
|
|
|
|
|
|
|
|
- # Unload loopbacks in reverse order
|
|
|
|
|
- for loopback in reversed(state.loopbacks):
|
|
|
|
|
|
|
+ # Unload null sink
|
|
|
try:
|
|
try:
|
|
|
- self.pa.unload_module(loopback.module_id)
|
|
|
|
|
|
|
+ self.pa.unload_module(state.null_sink_module)
|
|
|
except RuntimeError:
|
|
except RuntimeError:
|
|
|
- pass # Already gone is fine
|
|
|
|
|
|
|
+ pass
|
|
|
|
|
|
|
|
- # Unload remap source
|
|
|
|
|
- try:
|
|
|
|
|
- self.pa.unload_module(state.remap_module)
|
|
|
|
|
- except RuntimeError:
|
|
|
|
|
- pass
|
|
|
|
|
|
|
+ self.store.clear()
|
|
|
|
|
+ # Sweep any remaining orphans whose IDs changed since state was saved
|
|
|
|
|
+ self._sweep_orphans()
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # No state file — discover owned modules by name and unload them
|
|
|
|
|
+ self._sweep_orphans()
|
|
|
|
|
+ self.store.clear()
|
|
|
|
|
+
|
|
|
|
|
+ def _sweep_orphans(self) -> None:
|
|
|
|
|
+ """Unload any live modules that match our sink/remap names.
|
|
|
|
|
|
|
|
- # Unload null sink
|
|
|
|
|
|
|
+ Scans ``pactl list short modules`` and matches on the argument string
|
|
|
|
|
+ so cleanup works without ``audio.json`` (orphan case).
|
|
|
|
|
+ """
|
|
|
try:
|
|
try:
|
|
|
- self.pa.unload_module(state.null_sink_module)
|
|
|
|
|
- except RuntimeError:
|
|
|
|
|
- pass
|
|
|
|
|
|
|
+ output = self.pa.runner(["list", "short", "modules"])
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return
|
|
|
|
|
|
|
|
- # Always clear state
|
|
|
|
|
- self.store.clear()
|
|
|
|
|
|
|
+ loopbacks: list[int] = []
|
|
|
|
|
+ remap_id: int | None = None
|
|
|
|
|
+ null_sink_id: int | None = None
|
|
|
|
|
+ sink_name = self.cfg.sink_name
|
|
|
|
|
+ remap_name = self.cfg.remap_source_name
|
|
|
|
|
+ monitor_source = f"{sink_name}.monitor"
|
|
|
|
|
+
|
|
|
|
|
+ for line in output.strip().split("\n"):
|
|
|
|
|
+ if not line.strip():
|
|
|
|
|
+ continue
|
|
|
|
|
+ parts = line.split("\t")
|
|
|
|
|
+ if len(parts) < 2:
|
|
|
|
|
+ continue
|
|
|
|
|
+ try:
|
|
|
|
|
+ mid = int(parts[0])
|
|
|
|
|
+ except ValueError:
|
|
|
|
|
+ continue
|
|
|
|
|
+ name = parts[1]
|
|
|
|
|
+ args_str = "\t".join(parts[2:]) if len(parts) > 2 else ""
|
|
|
|
|
+ # also handle space-joined args in some pactl versions
|
|
|
|
|
+ if name == "module-null-sink" and f"sink_name={sink_name}" in args_str:
|
|
|
|
|
+ null_sink_id = mid
|
|
|
|
|
+ elif name == "module-remap-source" and f"source_name={remap_name}" in args_str:
|
|
|
|
|
+ remap_id = mid
|
|
|
|
|
+ elif name == "module-loopback":
|
|
|
|
|
+ if f"sink={sink_name}" in args_str or f"source={monitor_source}" in args_str:
|
|
|
|
|
+ loopbacks.append(mid)
|
|
|
|
|
+
|
|
|
|
|
+ for mid in reversed(loopbacks):
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.pa.unload_module(mid)
|
|
|
|
|
+ except RuntimeError:
|
|
|
|
|
+ pass
|
|
|
|
|
+ if remap_id is not None:
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.pa.unload_module(remap_id)
|
|
|
|
|
+ except RuntimeError:
|
|
|
|
|
+ pass
|
|
|
|
|
+ if null_sink_id is not None:
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.pa.unload_module(null_sink_id)
|
|
|
|
|
+ except RuntimeError:
|
|
|
|
|
+ pass
|
|
|
|
|
|
|
|
def ensure(self) -> AudioState | None:
|
|
def ensure(self) -> AudioState | None:
|
|
|
"""Ensure provisioning exists; re-provision if sink is missing.
|
|
"""Ensure provisioning exists; re-provision if sink is missing.
|