-- WirePlumber
--
-- Copyright © 2021-2022 Collabora Ltd.
-- @author George Kiagiadakis <[email protected]>
--
-- Based on restore-stream.c from pipewire-media-session
-- Copyright © 2020 Wim Taymans
--
-- SPDX-License-Identifier: MIT
cutils = require ("common-utils")
log = Log.open_topic ("s-node")
config = {}
config.rules = Conf.get_section_as_json ("stream.rules", Json.Array {})
-- the state storage
state = nil
state_table = nil
-- Support for the "System Sounds" volume control in pavucontrol
rs_metadata = nil
-- hook to restore stream properties & target
restore_stream_hook = SimpleEventHook {
name = "node/restore-stream",
interests = {
-- match stream nodes
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "matches", "Stream/*" },
},
-- and device nodes that are not associated with any routes
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "matches", "Audio/*" },
Constraint { "device.routes", "is-absent" },
},
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "matches", "Audio/*" },
Constraint { "device.routes", "equals", "0" },
},
},
execute = function (event)
local node = event:get_subject ()
local stream_props = node.properties
stream_props = JsonUtils.match_rules_update_properties (config.rules, stream_props)
local key = formKey (stream_props)
if not key then
return
end
local stored_values = getStoredStreamProps (key) or {}
-- restore node Props (volumes, channelMap, etc...)
if Settings.get_boolean ("node.stream.restore-props") and stream_props ["state.restore-props"] ~= "false"
then
local props = {
"Spa:Pod:Object:Param:Props", "Props",
volume = stored_values.volume,
mute = stored_values.mute,
channelVolumes = stored_values.channelVolumes ~= nil and
stored_values.channelVolumes or buildDefaultChannelVolumes (node),
channelMap = stored_values.channelMap,
}
-- convert arrays to Spa Pod
if props.channelVolumes then
table.insert (props.channelVolumes, 1, "Spa:Float")
props.channelVolumes = Pod.Array (props.channelVolumes)
end
if props.channelMap then
table.insert (props.channelMap, 1, "Spa:Enum:AudioChannel")
props.channelMap = Pod.Array (props.channelMap)
end
if props.volume or (props.mute ~= nil) or props.channelVolumes or props.channelMap
then
log:info (node, "restore values from " .. key)
local param = Pod.Object (props)
log:debug (param, "setting props on " .. tostring (stream_props ["node.name"]))
node:set_param ("Props", param)
end
end
-- restore the node's link target on metadata
if Settings.get_boolean ("node.stream.restore-target") and stream_props ["state.restore-target"] ~= "false"
then
if stored_values.target then
-- check first if there is a defined target in the node's properties
-- and skip restoring if this is the case (#335)
local target_in_props =
stream_props ["target.object"] or stream_props ["node.target"]
if not target_in_props then
local source = event:get_source ()
local nodes_om = source:call ("get-object-manager", "node")
local metadata_om = source:call ("get-object-manager", "metadata")
local target_node = nodes_om:lookup {
Constraint { "node.name", "=", stored_values.target, type = "pw" }
}
local metadata = metadata_om:lookup {
Constraint { "metadata.name", "=", "default" }
}
if target_node and metadata then
metadata:set (node ["bound-id"], "target.object", "Spa:Id",
target_node.properties ["object.serial"])
end
else
log:debug (node,
"Not restoring the target for " ..
tostring (stream_props ["node.name"]) ..
" because it is already set to " .. target_in_props)
end
end
end
end
}
-- store stream properties on the state file
store_stream_props_hook = SimpleEventHook {
name = "node/store-stream-props",
interests = {
-- match stream nodes
EventInterest {
Constraint { "event.type", "=", "node-params-changed" },
Constraint { "event.subject.param-id", "=", "Props" },
Constraint { "media.class", "matches", "Stream/*" },
},
-- and device nodes that are not associated with any routes
EventInterest {
Constraint { "event.type", "=", "node-params-changed" },
Constraint { "event.subject.param-id", "=", "Props" },
Constraint { "media.class", "matches", "Audio/*" },
Constraint { "device.routes", "is-absent" },
},
EventInterest {
Constraint { "event.type", "=", "node-params-changed" },
Constraint { "event.subject.param-id", "=", "Props" },
Constraint { "media.class", "matches", "Audio/*" },
Constraint { "device.routes", "equals", "0" },
},
},
execute = function (event)
local node = event:get_subject ()
local stream_props = node.properties
stream_props = JsonUtils.match_rules_update_properties (config.rules, stream_props)
if Settings.get_boolean ("node.stream.restore-props") and stream_props ["state.restore-props"] ~= "false"
then
local key = formKey (stream_props)
if not key then
return
end
local stored_values = getStoredStreamProps (key) or {}
local hasChanges = false
log:info (node, "saving stream props for " ..
tostring (stream_props ["node.name"]))
for p in node:iterate_params ("Props") do
local props = cutils.parseParam (p, "Props")
if not props then
goto skip_prop
end
if props.volume ~= stored_values.volume then
stored_values.volume = props.volume
hasChanges = true
end
if props.mute ~= stored_values.mute then
stored_values.mute = props.mute
hasChanges = true
end
if props.channelVolumes then
stored_values.channelVolumes = props.channelVolumes
hasChanges = true
end
if props.channelMap then
stored_values.channelMap = props.channelMap
hasChanges = true
end
::skip_prop::
end
if hasChanges then
saveStreamProps (key, stored_values)
end
end
end
}
-- save "target.node"/"target.object" on metadata changes
store_stream_target_hook = SimpleEventHook {
name = "node/store-stream-target-metadata-changed",
interests = {
EventInterest {
Constraint { "event.type", "=", "metadata-changed" },
Constraint { "metadata.name", "=", "default" },
Constraint { "event.subject.key", "c", "target.object", "target.node" },
},
},
execute = function (event)
local source = event:get_source ()
local nodes_om = source:call ("get-object-manager", "node")
local props = event:get_properties ()
local subject_id = props ["event.subject.id"]
local target_key = props ["event.subject.key"]
local target_value = props ["event.subject.value"]
local node = nodes_om:lookup {
Constraint { "bound-id", "=", subject_id, type = "gobject" }
}
if not node then
return
end
local stream_props = node.properties
stream_props = JsonUtils.match_rules_update_properties (config.rules, stream_props)
if stream_props ["state.restore-target"] == "false" then
return
end
local key = formKey (stream_props)
if not key then
return
end
local target_name = nil
if target_value and target_value ~= "-1" then
local target_node
if target_key == "target.object" then
target_node = nodes_om:lookup {
Constraint { "object.serial", "=", target_value, type = "pw-global" }
}
else
target_node = nodes_om:lookup {
Constraint { "bound-id", "=", target_value, type = "gobject" }
}
end
if target_node then
target_name = target_node.properties ["node.name"]
end
end
log:info (node, "saving stream target for " ..
tostring (stream_props ["node.name"]) .. " -> " .. tostring (target_name))
local stored_values = getStoredStreamProps (key) or {}
stored_values.target = target_name
saveStreamProps (key, stored_values)
end
}
-- populate route-settings metadata
function populateMetadata (metadata)
-- copy state into the metadata
local key = "Output/Audio:media.role:Notification"
local p = getStoredStreamProps (key)
if p then
p.channels = p.channelMap and Json.Array (p.channelMap)
p.volumes = p.channelVolumes and Json.Array (p.channelVolumes)
p.channelMap = nil
p.channelVolumes = nil
p.target = nil
-- pipewire-pulse expects the key to be
-- "restore.stream.Output/Audio.media.role:Notification"
key = string.gsub (key, ":", ".", 1);
metadata:set (0, "restore.stream." .. key, "Spa:String:JSON",
Json.Object (p):to_string ())
end
end
-- track route-settings metadata changes
route_settings_metadata_changed_hook = SimpleEventHook {
name = "node/route-settings-metadata-changed",
interests = {
EventInterest {
Constraint { "event.type", "=", "metadata-changed" },
Constraint { "metadata.name", "=", "route-settings" },
Constraint { "event.subject.key", "=",
"restore.stream.Output/Audio.media.role:Notification" },
Constraint { "event.subject.spa_type", "=", "Spa:String:JSON" },
Constraint { "event.subject.value", "is-present" },
},
},
execute = function (event)
local props = event:get_properties ()
local subject_id = props ["event.subject.id"]
local key = props ["event.subject.key"]
local value = props ["event.subject.value"]
local json = Json.Raw (value)
if json == nil or not json:is_object () then
return
end
local vparsed = json:parse ()
-- we store the key as "Output/Audio:media.role:Notification"
local key = string.sub (key, string.len ("restore.stream.") + 1)
key = string.gsub (key, "%.", ":", 1);
local stored_values = getStoredStreamProps (key) or {}
if vparsed.volume ~= nil then
stored_values.volume = vparsed.volume
end
if vparsed.mute ~= nil then
stored_values.mute = vparsed.mute
end
if vparsed.channels ~= nil then
stored_values.channelMap = vparsed.channels
end
if vparsed.volumes ~= nil then
stored_values.channelVolumes = vparsed.volumes
end
saveStreamProps (key, stored_values)
end
}
function buildDefaultChannelVolumes (node)
local node_props = node.properties
local direction = cutils.mediaClassToDirection (node_props ["media.class"] or "")
local def_vol = 1.0
local channels = 2
local res = {}
local str = node.properties["state.default-volume"]
if str ~= nil then
def_vol = tonumber (str)
elseif direction == "input" then
def_vol = Settings.get_float ("node.stream.default-capture-volume")
elseif direction == "output" then
def_vol = Settings.get_float ("node.stream.default-playback-volume")
end
for pod in node:iterate_params("Format") do
local pod_parsed = pod:parse()
if pod_parsed ~= nil then
channels = pod_parsed.properties.channels
break
end
end
log:info (node, "using default volume: " .. tostring(def_vol) ..
", channels: " .. tostring(channels))
while (#res < channels) do
table.insert(res, def_vol)
end
return res
end
function getStoredStreamProps (key)
local value = state_table [key]
if not value then
return nil
end
local json = Json.Raw (value)
if not json or not json:is_object () then
return nil
end
return json:parse ()
end
function saveStreamProps (key, p)
assert (type (p) == "table")
p.channelMap = p.channelMap and Json.Array (p.channelMap)
p.channelVolumes = p.channelVolumes and Json.Array (p.channelVolumes)
state_table [key] = Json.Object (p):to_string ()
state:save_after_timeout (state_table)
end
function formKey (properties)
local keys = {
"media.role",
"application.id",
"application.name",
"media.name",
"node.name",
}
local key_base = nil
for _, k in ipairs (keys) do
local p = properties [k]
if p then
key_base = string.format ("%s:%s:%s",
properties ["media.class"]:gsub ("^Stream/", ""), k, p)
break
end
end
return key_base
end
function toggleState (enable)
if enable and not state then
state = State ("stream-properties")
state_table = state:load ()
restore_stream_hook:register ()
store_stream_props_hook:register ()
store_stream_target_hook:register ()
route_settings_metadata_changed_hook:register ()
rs_metadata = ImplMetadata ("route-settings")
rs_metadata:activate (Features.ALL, function (m, e)
if e then
log:warning ("failed to activate route-settings metadata: " .. tostring (e))
else
populateMetadata (m)
end
end)
elseif not enable and state then
state = nil
state_table = nil
restore_stream_hook:remove ()
store_stream_props_hook:remove ()
store_stream_target_hook:remove ()
route_settings_metadata_changed_hook:remove ()
rs_metadata = nil
end
end
Settings.subscribe ("node.stream.restore-props", function ()
toggleState (Settings.get_boolean ("node.stream.restore-props") or
Settings.get_boolean ("node.stream.restore-target"))
end)
Settings.subscribe ("node.stream.restore-target", function ()
toggleState (Settings.get_boolean ("node.stream.restore-props") or
Settings.get_boolean ("node.stream.restore-target"))
end)
toggleState (Settings.get_boolean ("node.stream.restore-props") or
Settings.get_boolean ("node.stream.restore-target"))