-- WirePlumber -- -- Copyright © 2021-2022 Collabora Ltd. -- @author George Kiagiadakis -- -- 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"))