Skip to content

Commit a771adf

Browse files
committed
Refresh default metadata snapshots on output events
1 parent 195d0d4 commit a771adf

5 files changed

Lines changed: 158 additions & 32 deletions

File tree

src/mini_eq/pipewire_backend.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,14 @@ def defaults(self) -> PipeWireDefaults:
614614

615615
return self.refresh_defaults()
616616

617-
def refresh_defaults(self) -> PipeWireDefaults:
617+
def refresh_defaults(self, *, snapshot: bool = False) -> PipeWireDefaults:
618+
snapshot_error: Exception | None = None
619+
if snapshot:
620+
try:
621+
self._resnapshot_metadata()
622+
except Exception as exc:
623+
snapshot_error = exc
624+
618625
try:
619626
self._cached_defaults = self._read_defaults()
620627
return self._cached_defaults
@@ -636,6 +643,10 @@ def refresh_defaults(self) -> PipeWireDefaults:
636643
except Exception:
637644
if self._has_cached_defaults():
638645
return self._cached_defaults
646+
if snapshot_error is not None:
647+
raise PipeWireBackendError(f"failed to refresh PipeWire metadata snapshot: {snapshot_error}") from (
648+
snapshot_error
649+
)
639650
raise
640651

641652
def remember_default_metadata_change(self, key: str, value: str | None) -> bool:
@@ -660,6 +671,22 @@ def _read_defaults(self) -> PipeWireDefaults:
660671
def _has_cached_defaults(self) -> bool:
661672
return bool(self._cached_defaults.default_audio_sink or self._cached_defaults.configured_audio_sink)
662673

674+
def _resnapshot_metadata(self) -> None:
675+
if self._metadata is None:
676+
return
677+
678+
try:
679+
# Rebinding asks PipeWire for the current metadata properties. This
680+
# covers clients that missed property events while the metadata
681+
# resource was pending, without polling or sleeping.
682+
self._metadata.stop()
683+
if not self._metadata.start():
684+
raise PipeWireBackendError("failed to restart PipeWire default metadata discovery")
685+
self._sync_metadata()
686+
self._cached_defaults = PipeWireDefaults(None, None)
687+
except Exception as exc:
688+
raise PipeWireBackendError(f"failed to refresh PipeWire metadata snapshot: {exc}") from exc
689+
663690
def move_stream_to_target(self, stream_bound_id: int, target_node_name: str) -> None:
664691
stream = self.output_stream_by_bound_id(stream_bound_id)
665692
if stream is None:

src/mini_eq/routing.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -169,14 +169,16 @@ def output_preset_target(self, *, refresh: bool = False) -> PipeWireOutputPreset
169169
self._output_preset_target = target
170170
return target
171171

172-
def default_output_sink_candidates(self, *, refresh: bool = False) -> tuple[str, ...]:
173-
defaults = self.output_backend.refresh_defaults() if refresh else self.output_backend.defaults()
172+
def default_output_sink_candidates(self, *, refresh: bool = False, snapshot: bool = False) -> tuple[str, ...]:
173+
defaults = (
174+
self.output_backend.refresh_defaults(snapshot=snapshot) if refresh else self.output_backend.defaults()
175+
)
174176
return tuple(
175177
sink_name for sink_name in (defaults.configured_audio_sink, defaults.default_audio_sink) if sink_name
176178
)
177179

178-
def get_default_output_sink_name(self, *, refresh: bool = False) -> str:
179-
candidates = self.default_output_sink_candidates(refresh=refresh)
180+
def get_default_output_sink_name(self, *, refresh: bool = False, snapshot: bool = False) -> str:
181+
candidates = self.default_output_sink_candidates(refresh=refresh, snapshot=snapshot)
180182

181183
for sink_name in candidates:
182184
if self.is_valid_output_sink(sink_name) and self.get_sink(sink_name) is not None:
@@ -231,6 +233,7 @@ def prepare_output_analyzer(self) -> bool:
231233
return analyzer.prepare()
232234

233235
def set_analyzer_enabled(self, enabled: bool) -> bool:
236+
self.refresh_followed_output_sink(snapshot=True)
234237
analyzer = self.ensure_output_analyzer()
235238

236239
if not enabled:
@@ -304,15 +307,15 @@ def switch_output_sink(self, sink_name: str, explicit: bool) -> None:
304307
def follow_system_default_output(self) -> None:
305308
previous_output_sink = getattr(self, "output_sink", None)
306309
self.follow_default_output = True
307-
self.refresh_followed_output_sink()
310+
self.refresh_followed_output_sink(snapshot=True)
308311
if getattr(self, "output_sink", None) != previous_output_sink:
309312
self.schedule_output_event_refresh()
310313

311-
def refresh_followed_output_sink(self) -> bool:
312-
if not self.follow_default_output:
314+
def refresh_followed_output_sink(self, *, snapshot: bool = False) -> bool:
315+
if not getattr(self, "follow_default_output", False):
313316
return False
314317

315-
for default_sink in self.default_output_sink_candidates(refresh=True):
318+
for default_sink in self.default_output_sink_candidates(refresh=True, snapshot=snapshot):
316319
if not self.is_valid_output_sink(default_sink) or self.get_sink(default_sink) is None:
317320
continue
318321
try:
@@ -334,7 +337,7 @@ def refresh_followed_output_sink_from_event(self, sink_name: str | None) -> bool
334337
self.emit_status(f"default output follow warning: {exc}")
335338
return True
336339

337-
return self.refresh_followed_output_sink()
340+
return self.refresh_followed_output_sink(snapshot=True)
338341

339342
def schedule_output_event_refresh(self) -> None:
340343
if not getattr(self, "accept_output_events", False):
@@ -424,7 +427,7 @@ def on_output_event_idle(self) -> bool:
424427
if pending_followed_output_sink is not None:
425428
self.refresh_followed_output_sink_from_event(pending_followed_output_sink)
426429
else:
427-
self.refresh_followed_output_sink()
430+
self.refresh_followed_output_sink(snapshot=True)
428431
self.refresh_output_route_param_monitor()
429432

430433
if self.outputs_changed_callback is not None:
@@ -451,7 +454,7 @@ def start_output_event_monitoring(self) -> None:
451454
)
452455

453456
self.invalidate_output_preset_target()
454-
self.refresh_followed_output_sink()
457+
self.refresh_followed_output_sink(snapshot=True)
455458
self.refresh_output_route_param_monitor()
456459

457460
if self.outputs_changed_callback is not None:

tests/test_mini_eq_pipewire_backend.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,6 +1439,34 @@ def raise_decode_error():
14391439
assert syncs == [True]
14401440

14411441

1442+
def test_refresh_defaults_can_resnapshot_metadata_before_read(monkeypatch) -> None:
1443+
backend = pw_backend.PipeWireBackend()
1444+
calls: list[str] = []
1445+
1446+
class FakeMetadata:
1447+
def stop(self) -> None:
1448+
calls.append("stop")
1449+
1450+
def start(self) -> bool:
1451+
calls.append("start")
1452+
return True
1453+
1454+
backend._metadata = FakeMetadata()
1455+
backend._cached_defaults = pw_backend.PipeWireDefaults("stale.default", "stale.configured")
1456+
monkeypatch.setattr(backend, "_sync_metadata", lambda: calls.append("sync"))
1457+
monkeypatch.setattr(
1458+
backend,
1459+
"_read_defaults",
1460+
lambda: calls.append("read") or pw_backend.PipeWireDefaults("fresh.default", "fresh.configured"),
1461+
)
1462+
1463+
assert backend.refresh_defaults(snapshot=True) == pw_backend.PipeWireDefaults(
1464+
"fresh.default",
1465+
"fresh.configured",
1466+
)
1467+
assert calls == ["stop", "start", "sync", "read"]
1468+
1469+
14421470
def test_remember_default_metadata_change_updates_cache() -> None:
14431471
backend = pw_backend.PipeWireBackend()
14441472

tests/test_mini_eq_routing.py

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,14 @@ def __init__(
5353
self.cached_defaults = cached_defaults
5454
self.refreshed_defaults = refreshed_defaults
5555
self.refresh_count = 0
56+
self.snapshot_values: list[bool] = []
5657

5758
def defaults(self) -> pw_backend.PipeWireDefaults:
5859
return self.cached_defaults
5960

60-
def refresh_defaults(self) -> pw_backend.PipeWireDefaults:
61+
def refresh_defaults(self, *, snapshot: bool = False) -> pw_backend.PipeWireDefaults:
6162
self.refresh_count += 1
63+
self.snapshot_values.append(snapshot)
6264
return self.refreshed_defaults
6365

6466

@@ -211,9 +213,35 @@ def test_refresh_followed_output_sink_refreshes_metadata_and_skips_virtual_defau
211213

212214
assert routing.SystemWideEqController.refresh_followed_output_sink(controller) is True
213215
assert backend.refresh_count == 1
216+
assert backend.snapshot_values == [False]
214217
assert calls == [("speakers", False)]
215218

216219

220+
def test_output_event_idle_refreshes_followed_sink_from_metadata_snapshot() -> None:
221+
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
222+
backend = FakeDefaultOutputBackend(
223+
[make_node(1, "hdmi")],
224+
cached_defaults=pw_backend.PipeWireDefaults("speakers", None),
225+
refreshed_defaults=pw_backend.PipeWireDefaults("hdmi", None),
226+
)
227+
controller.accept_output_events = True
228+
controller.output_event_source_id = 123
229+
controller.pending_followed_output_sink = None
230+
controller.follow_default_output = True
231+
controller.output_backend = backend
232+
controller._output_preset_target_sink = "speakers"
233+
controller._output_preset_target = pw_routes.PipeWireOutputPresetTarget("speakers", None, ("speakers",))
234+
calls: list[object] = []
235+
controller.switch_output_sink = lambda sink_name, explicit: calls.append(("switch", sink_name, explicit))
236+
controller.refresh_output_route_param_monitor = lambda: calls.append("route-monitor")
237+
controller.outputs_changed_callback = lambda: calls.append("outputs")
238+
239+
assert routing.SystemWideEqController.on_output_event_idle(controller) is False
240+
241+
assert backend.snapshot_values == [True]
242+
assert calls == [("switch", "hdmi", False), "route-monitor", "outputs"]
243+
244+
217245
def test_output_metadata_change_schedules_one_refresh(monkeypatch: pytest.MonkeyPatch) -> None:
218246
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
219247
controller.accept_output_events = True
@@ -296,7 +324,7 @@ def test_output_event_idle_uses_pending_followed_sink_before_metadata_refresh()
296324
controller._output_preset_target = pw_routes.PipeWireOutputPresetTarget("speakers", None, ("speakers",))
297325
calls: list[object] = []
298326
controller.switch_output_sink = lambda sink_name, explicit: calls.append(("switch", sink_name, explicit))
299-
controller.refresh_followed_output_sink = lambda: calls.append("refresh")
327+
controller.refresh_followed_output_sink = lambda **_kwargs: calls.append("refresh")
300328
controller.refresh_output_route_param_monitor = lambda: calls.append("route-monitor")
301329
controller.outputs_changed_callback = lambda: calls.append("outputs")
302330

@@ -340,7 +368,7 @@ def test_output_event_idle_invalidates_output_preset_target_cache() -> None:
340368
controller._output_preset_target_sink = "speakers"
341369
controller._output_preset_target = pw_routes.PipeWireOutputPresetTarget("speakers", None, ("speakers",))
342370
calls: list[str] = []
343-
controller.refresh_followed_output_sink = lambda: calls.append("refresh")
371+
controller.refresh_followed_output_sink = lambda **_kwargs: calls.append("refresh")
344372
controller.outputs_changed_callback = lambda: calls.append("outputs")
345373

346374
assert routing.SystemWideEqController.on_output_event_idle(controller) is False
@@ -431,7 +459,7 @@ def disconnect_device_handler(self, handler_id: int) -> None:
431459
controller.output_route_param_handler_id = 0
432460
controller.output_route_param_device_id = 0
433461
controller.follow_default_output = False
434-
controller.refresh_followed_output_sink = lambda: calls.append("refresh")
462+
controller.refresh_followed_output_sink = lambda **_kwargs: calls.append("refresh")
435463
controller.outputs_changed_callback = lambda: calls.append("outputs")
436464

437465
monkeypatch.setattr(routing.GLib, "idle_add", lambda callback: calls.append(("idle", callback)) or 321)
@@ -492,7 +520,7 @@ def test_follow_system_default_output_enables_follow_mode_and_refreshes() -> Non
492520
controller.follow_default_output = False
493521
calls: list[str] = []
494522

495-
def fake_refresh() -> bool:
523+
def fake_refresh(**_kwargs) -> bool:
496524
calls.append("refresh")
497525
return True
498526

@@ -512,7 +540,7 @@ def test_follow_system_default_output_schedules_refresh_when_output_changes(monk
512540
controller.output_event_source_id = 0
513541
scheduled_callbacks: list[object] = []
514542

515-
def fake_refresh() -> bool:
543+
def fake_refresh(**_kwargs) -> bool:
516544
controller.output_sink = "hdmi"
517545
return True
518546

@@ -956,7 +984,7 @@ def test_start_prepares_analyzer_before_filter_chain_engine() -> None:
956984
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
957985
calls: list[str] = []
958986

959-
controller.refresh_followed_output_sink = lambda: calls.append("refresh")
987+
controller.refresh_followed_output_sink = lambda **_kwargs: calls.append("refresh")
960988
controller.prepare_output_analyzer = lambda: calls.append("prepare") or True
961989
controller.start_engine = lambda *, on_ready=None, on_error=None: (calls.append("engine"), on_ready and on_ready())
962990
controller.start_output_event_monitoring = lambda: calls.append("monitor")
@@ -1030,7 +1058,7 @@ def close(self) -> None:
10301058
controller.stream_router = FakeStreamRouter()
10311059
controller.output_analyzer = None
10321060
controller.output_backend = FakeBackend()
1033-
controller.refresh_followed_output_sink = lambda: calls.append("refresh")
1061+
controller.refresh_followed_output_sink = lambda **_kwargs: calls.append("refresh")
10341062
controller.stop_output_event_monitoring = lambda: calls.append("stop-monitor")
10351063
controller.engine_module = object()
10361064
controller.filter_node_id = 42
@@ -1059,7 +1087,7 @@ def test_route_system_audio_does_not_enable_during_shutdown() -> None:
10591087
controller.routed = False
10601088
calls: list[str] = []
10611089

1062-
controller.refresh_followed_output_sink = lambda: calls.append("refresh")
1090+
controller.refresh_followed_output_sink = lambda **_kwargs: calls.append("refresh")
10631091
controller.ensure_stream_router = lambda: calls.append("router")
10641092

10651093
routing.SystemWideEqController.route_system_audio(controller, True)
@@ -1077,7 +1105,7 @@ def test_route_system_audio_requires_ready_engine_before_enabling() -> None:
10771105
controller.eq_enabled = True
10781106
calls: list[str] = []
10791107

1080-
controller.refresh_followed_output_sink = lambda: calls.append("refresh")
1108+
controller.refresh_followed_output_sink = lambda **_kwargs: calls.append("refresh")
10811109
controller.ensure_stream_router = lambda: calls.append("router")
10821110

10831111
with pytest.raises(RuntimeError, match="filter-chain PipeWire EQ is not ready"):
@@ -1105,7 +1133,7 @@ def set_eq_enabled(enabled: bool) -> None:
11051133
calls.append(f"eq:{enabled}")
11061134
controller.eq_enabled = enabled
11071135

1108-
controller.refresh_followed_output_sink = lambda: calls.append("refresh")
1136+
controller.refresh_followed_output_sink = lambda **_kwargs: calls.append("refresh")
11091137
controller.set_eq_enabled = set_eq_enabled
11101138
controller.ensure_stream_router = lambda: calls.append("router") or FakeRouter()
11111139
controller.apply_state_to_engine = lambda: calls.append("apply")
@@ -1139,7 +1167,7 @@ class FakeRouter:
11391167
def enable(self) -> None:
11401168
calls.append("enable")
11411169

1142-
controller.refresh_followed_output_sink = lambda: calls.append("refresh")
1170+
controller.refresh_followed_output_sink = lambda **_kwargs: calls.append("refresh")
11431171
controller.ensure_stream_router = lambda: calls.append("router") or FakeRouter()
11441172
controller.apply_state_to_engine = lambda: calls.append("apply")
11451173
controller.emit_status = lambda message: calls.append(f"status:{message}")
@@ -1174,7 +1202,7 @@ def set_eq_enabled(enabled: bool) -> None:
11741202
calls.append(f"eq:{enabled}")
11751203
controller.eq_enabled = enabled
11761204

1177-
controller.refresh_followed_output_sink = lambda: calls.append("refresh")
1205+
controller.refresh_followed_output_sink = lambda **_kwargs: calls.append("refresh")
11781206
controller.set_eq_enabled = set_eq_enabled
11791207
controller.ensure_stream_router = lambda: calls.append("router") or FailingRouter()
11801208

@@ -1201,7 +1229,7 @@ def set_eq_enabled(enabled: bool) -> None:
12011229
if enabled:
12021230
raise RuntimeError("control update failed")
12031231

1204-
controller.refresh_followed_output_sink = lambda: calls.append("refresh")
1232+
controller.refresh_followed_output_sink = lambda **_kwargs: calls.append("refresh")
12051233
controller.set_eq_enabled = set_eq_enabled
12061234
controller.ensure_stream_router = lambda: calls.append("router")
12071235

@@ -1225,7 +1253,7 @@ class FakeRouter:
12251253
def disable(self, announce: bool = True) -> None:
12261254
calls.append(f"disable:{announce}")
12271255

1228-
controller.refresh_followed_output_sink = lambda: calls.append("refresh")
1256+
controller.refresh_followed_output_sink = lambda **_kwargs: calls.append("refresh")
12291257
controller.ensure_stream_router = lambda: calls.append("router") or FakeRouter()
12301258
controller.emit_status = lambda message: calls.append(f"status:{message}")
12311259

0 commit comments

Comments
 (0)