Your IP : 216.73.216.104
-- WirePlumber
--
-- Copyright © 2025 Pauli Virtanen
-- @author Pauli Virtanen <pav@iki.fi>
--
-- SPDX-License-Identifier: MIT
--
-- Pause media player applications when (one of) their output
-- target(s) goes away.
cutils = require ("common-utils")
lutils = require ("linking-utils")
log = Log.open_topic ("s-linking.mpris")
mpris = Plugin.find ("mpris")
RESCAN_DELAY_MSEC = 1000
function initializeState ()
-- Delaying rescan while pausing players
pending_ops = 0
need_rescan = false
-- Links between nodes: links_in [in_node_id] = { out_node_id, ... }
links_in = {}
links_initialized = false
-- Link nodes: link_nodes [link.id] = { in_node_id, out_node_id }
link_nodes = {}
-- Status
script_active = false
end
-- Get nodes that are (indirectly) linked to `si` by link group
-- or links with input direction. Returns table { [node_id] = node, ... }
function getLinkedNodes (start_id)
local node_om = cutils.get_object_manager ("node")
local groups = {}
local link_groups = {}
log:trace(string.format("start %d", start_id))
-- construct groups based on node.link-group
for node in node_om:iterate () do
local id = node ["bound-id"]
if groups [id] ~= nil then
return
end
local link_group = node.properties ["node.link-group"]
local group = nil
-- join via link groups
if link_group ~= nil then
group = groups [link_groups [link_group]]
link_groups [link_group] = id
end
if group == nil then
group = {}
end
group [id] = node
groups [id] = group
end
-- follow links
local group = {}
local active = { [start_id] = true }
while true do
local a_id = next(active)
if a_id == nil then
break
end
active [a_id] = nil
local b_ids = links_in [a_id]
if b_ids ~= nil then
for b_id in pairs (b_ids) do
if group [b_id] == nil and groups [b_id] ~= nil then
for k, v in pairs (groups [b_id]) do
if group [k] == nil then
active [k] = true
group [k] = v
log:trace(string.format("node %d", k))
end
end
end
end
end
end
return group
end
-- Track link status.
--
-- We need to know what state links were in just before a sink node is
-- removed. We rely here on the following facts:
--
-- * When node is removed existing links to it are removed by server synchronously
-- * Although it appears server notifies link removal *after* the node, use Core sync
-- to delay handling link removal after node removal, to be sure
-- * event_priority(node-removed, session-item-removed) > event_priority(link-removed)
-- then ensures we first handle item removal, then update links
function updateLink (in_id, out_id, remove)
if links_in [in_id] == nil then
links_in [in_id] = {}
end
if remove then
links_in [in_id] [out_id] = nil
else
links_in [in_id] [out_id] = true
end
end
function updateLinks (links_om)
log:debug ("update links")
for link in links_om:iterate () do
local lprops = link.properties
local in_id = tonumber (lprops ["link.input.node"])
local out_id = tonumber (lprops ["link.output.node"])
updateLink (in_id, out_id, false)
link_nodes [link.id] = { in_id, out_id }
end
end
function initializeLinks (source)
if links_initialized then
return
end
if source == nil then
-- postpone to later
return
end
local links_om = source:call ("get-object-manager", "link")
updateLinks(links_om)
links_initialized = true
end
link_hook = SimpleEventHook {
name = "linking/mpris-pause@track-links",
interests = {
EventInterest {
Constraint { "event.type", "c", "link-added", "link-removed" },
},
},
execute = function (event)
local link = event:get_subject ()
local eprops = event:get_properties ()
local source = event:get_source ()
initializeLinks (source)
local in_id
local out_id
local remove = false
if eprops ["event.type"] == "link-added" then
local lprops = link.properties
in_id = tonumber (lprops ["link.input.node"])
out_id = tonumber (lprops ["link.output.node"])
link_nodes [link.id] = { in_id, out_id }
elseif link_nodes [link.id] ~= nil then
in_id = link_nodes [link.id] [1]
out_id = link_nodes [link.id] [2]
link_nodes [link.id] = nil
remove = true
end
if in_id == nil or out_id == nil then
return
end
Core.sync(function()
updateLink (in_id, out_id, remove)
end)
end
}
-- Pause media applications associated with the streams linked to a sink to be removed
pause_hook = SimpleEventHook {
name = "linking/mpris-pause",
before = "linking/linkable-removed",
interests = {
EventInterest {
Constraint { "event.type", "=", "session-item-removed" },
Constraint { "item.node.type", "=", "device" },
Constraint { "item.node.direction", "=", "input" },
},
},
execute = function (event)
local players = mpris:call ("get-players")
if next(players) == nil then
return
end
-- find clients to handle
local si = event:get_subject ()
local source = event:get_source ()
local client_om = source:call ("get-object-manager", "client")
local node = si:get_associated_proxy ("node")
local node_group = getLinkedNodes (tonumber (si.properties ["node.id"]))
local client_ids = {}
for id, node in pairs (node_group) do
local media_class = tostring(node.properties ["media.class"])
if media_class:find ("^Stream/Output") then
client_ids [node.properties ["client.id"]] = true
end
end
-- find players to handle
local pause_players = {}
for client_id in pairs (client_ids) do
local client = client_om:lookup {
Constraint { "bound-id", "=", client_id, type = "gobject" }
}
if not client then
goto next
end
log:debug (si, string.format ("node %s removed: was linked to client %s (%s pid %s)",
tostring(si.properties ["node.id"]),
tostring(client.properties ["application.name"]),
tostring(client.properties ["application.id"]),
tostring(client.properties ["application.process.id"])))
for _, player in ipairs(players) do
local match = false
if client.properties ["pipewire.access.portal.app_id"] == nil and player ["flatpak-app-id"] == nil then
-- assume non-flatpak apps serve audio from same or child process as MPRIS
if player ["pid"] ~= nil then
local keys = { "pipewire.sec.pid", "application.process.id" }
for _, key in ipairs(keys) do
local pid = tonumber (client.properties [key])
if pid ~= nil and mpris:call ("match-pid", player ["pid"], pid) then
log:debug (si, string.format(".. match %s %u ~ %u", key, pid, player ["pid"]))
match = true
end
end
end
elseif client.properties ["pipewire.access.portal.app_id"] == player ["flatpak-app-id"] then
local instance_id = client.properties ["pipewire.access.portal.instance_id"]
if instance_id == nil then
-- Only new Pipewire versions have instance_id
log:debug (si, string.format(".. match pipewire.access.portal.app_id = %s", player ["flatpak-app-id"]))
match = true
elseif instance_id == player ["flatpak-instance-id"] then
log:debug (si, string.format(".. match pipewire.access.portal.app_id = %s instance_id = %s",
player ["flatpak-app-id"], player ["flatpak-instance-id"]))
match = true
end
end
if match then
pause_players [player ["name"]] = true
end
end
::next::
end
-- handle players
for bus_name in pairs(pause_players) do
log:info (si, string.format("node %s removed: pausing linked media player %s",
tostring(si.properties ["node.id"]),
bus_name))
local op = mpris:call ("pause", bus_name)
if op ["result"] == 0 then
log:debug ("pause pending")
pending_ops = pending_ops + 1
op:connect("notify::result", function (op, pspec)
if pending_ops > 0 then
pending_ops = pending_ops - 1
end
log:debug (string.format("pause completed, res = %d, %d remaining",
op ["result"], pending_ops))
if pending_ops == 0 and need_rescan then
-- Some players respond to DBus before actually pausing output,
-- so add also a small delay
Core.timeout_add(RESCAN_DELAY_MSEC, function()
log:debug ("continue rescan")
source:call ("push-event", "rescan-for-linking", nil, nil)
end)
end
end)
end
end
end
}
-- Do not perform rescans while we are pausing media players
rescan_hook = SimpleEventHook {
name = "linking/mpris-pause-disable-rescan",
before = "linking/rescan",
interests = {
EventInterest {
Constraint { "event.type", "=", "rescan-for-linking" },
},
},
execute = function (event)
if pending_ops > 0 then
log:info ("rescan disabled, wait for pending operations")
need_rescan = true
event:stop_processing ()
else
need_rescan = false
end
end
}
function updateEnabled ()
local enable = Settings.get_boolean ("linking.pause-playback")
log:debug (string.format ("enabled: %s", tostring(enable)))
if enable and not script_active then
local source = Plugin.find ("standard-event-source")
initializeState ()
initializeLinks (source)
link_hook:register ()
pause_hook:register ()
rescan_hook:register ()
script_active = true
elseif not enable and script_active then
link_hook:remove ()
pause_hook:remove ()
rescan_hook:remove ()
if need_rescan then
local source = Plugin.find ("standard-event-source")
source:call ("push-event", "rescan-for-linking", nil, nil)
end
initializeState ()
end
end
Settings.subscribe ("linking.pause-playback", updateEnabled)
updateEnabled ()