From 82be02e8fab55367bd094c6507da997852f06b2c Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Thu, 4 Dec 2025 10:53:22 -0500 Subject: [PATCH 01/17] lib: Add new WpCollection API This API allows grouping globals into collections. A collection is essentially a metadata object with information about all the globals it collects. Grouping globals into collections has the advantage of avoiding defining complex set of properties to match the interested globals, and they are also a more generic way to represent a set of globals that share something in common. --- lib/wp/collection.c | 462 ++++++++++++++++++++++++++++++++++++ lib/wp/collection.h | 49 ++++ lib/wp/meson.build | 2 + lib/wp/private/collection.h | 27 +++ lib/wp/wp.h | 1 + 5 files changed, 541 insertions(+) create mode 100644 lib/wp/collection.c create mode 100644 lib/wp/collection.h create mode 100644 lib/wp/private/collection.h diff --git a/lib/wp/collection.c b/lib/wp/collection.c new file mode 100644 index 00000000..12569f33 --- /dev/null +++ b/lib/wp/collection.c @@ -0,0 +1,462 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#include + +#include "core.h" +#include "collection.h" +#include "metadata.h" +#include "log.h" +#include "object-manager.h" + +#include "private/collection.h" + +WP_DEFINE_LOCAL_LOG_TOPIC ("wp-collection") + +/*! \defgroup wpcollection WpCollection */ +/*! + * \struct WpCollection + * + * The WpCollection class allows grouping multiple WirePlumber global proxies + * together so they can be treated as a single unit. + * + * WpCollection API. + */ + +struct _WpCollection +{ + WpObject parent; + + gchar *name; + + WpObjectManager *metadata_om; + GWeakRef metadata; + GHashTable *globals_info; +}; + +enum { + PROP_0, + PROP_NAME, +}; + +G_DEFINE_TYPE (WpCollection, wp_collection, WP_TYPE_OBJECT) + +static void +wp_collection_init (WpCollection * self) +{ + g_weak_ref_init (&self->metadata, NULL); + + self->globals_info = g_hash_table_new_full (g_direct_hash, g_direct_equal, + NULL, (GDestroyNotify) wp_spa_json_unref); +} + +static void +wp_collection_set_property (GObject * object, guint property_id, + const GValue * value, GParamSpec * pspec) +{ + WpCollection *self = WP_COLLECTION (object); + + switch (property_id) { + case PROP_NAME: + g_clear_pointer (&self->name, g_free); + self->name = g_value_dup_string (value); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +static void +wp_collection_get_property (GObject * object, guint property_id, + GValue * value, GParamSpec * pspec) +{ + WpCollection *self = WP_COLLECTION (object); + + switch (property_id) { + case PROP_NAME: + g_value_set_string (value, self->name); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +enum { + STEP_LOAD = WP_TRANSITION_STEP_CUSTOM_START, +}; + +static WpObjectFeatures +wp_collection_get_supported_features (WpObject * self) +{ + return WP_COLLECTION_LOADED; +} + +static guint +wp_collection_activate_get_next_step (WpObject * self, + WpFeatureActivationTransition * transition, guint step, + WpObjectFeatures missing) +{ + g_return_val_if_fail (missing == WP_COLLECTION_LOADED, + WP_TRANSITION_STEP_ERROR); + + return STEP_LOAD; +} + +static void +on_metadata_changed (WpMetadata *m, guint32 subject, + const gchar *key, const gchar *type, const gchar *value, gpointer d) +{ + WpCollection *self = WP_COLLECTION (d); + + if (key) { + WpProperties *info; + guint32 global_id = SPA_ID_INVALID; + + /* Parse global Id */ + if (!spa_atou32 (key, &global_id, 10)) { + wp_warning_object (self, + "Failed to parse global Id from metadata key '%s'. Ignoring...", key); + return; + } + + /* Check if value is valid */ + if (value) { + g_autoptr (WpSpaJson) json_val = wp_spa_json_new_from_string (value); + gboolean val = FALSE; + if (!wp_spa_json_is_boolean (json_val) || + !wp_spa_json_parse_boolean (json_val, &val) || !val) { + wp_warning_object (self, + "Metadata value '%s' is not a JSON boolean value set to TRUE. " + "Ignoring change...", value); + return; + } + + info = g_hash_table_lookup (self->globals_info, + GUINT_TO_POINTER (global_id)); + if (!info) { + WpSpaJson *new_info = wp_spa_json_new_boolean (TRUE); + g_hash_table_insert (self->globals_info, GUINT_TO_POINTER (global_id), + new_info); + wp_info_object (self, "Added global Id %u", global_id); + } + } else { + g_hash_table_remove (self->globals_info, GUINT_TO_POINTER (global_id)); + wp_info_object (self, "Removed global Id %u", global_id); + } + } else { + g_hash_table_remove_all (self->globals_info); + wp_info_object (self, "Removed all global Ids"); + } +} + +static void +on_metadata_added (WpObjectManager *om, WpMetadata *m, gpointer d) +{ + WpTransition * transition = WP_TRANSITION (d); + WpCollection * self = wp_transition_get_source_object (transition); + g_autoptr (WpProperties) props = NULL; + const gchar *metadata_name = NULL; + g_autoptr (WpIterator) it = NULL; + g_auto (GValue) item = G_VALUE_INIT; + + /* Make sure the metadata has a name */ + props = wp_global_proxy_get_global_properties (WP_GLOBAL_PROXY (m)); + g_return_if_fail (props); + metadata_name = wp_properties_get (props, "metadata.name"); + g_return_if_fail (metadata_name); + g_return_if_fail (g_str_equal (metadata_name, self->name)); + + /* Listen for metadata changes */ + g_signal_connect_object (m, "changed", G_CALLBACK (on_metadata_changed), + self, 0); + + /* Update the globals info table with the information from metadata */ + it = wp_metadata_new_iterator (m, 0); + for (; wp_iterator_next (it, &item); g_value_unset (&item)) { + WpMetadataItem *mi = g_value_get_boxed (&item); + const gchar *key = wp_metadata_item_get_key (mi); + const gchar *value = wp_metadata_item_get_value (mi); + guint32 global_id = SPA_ID_INVALID; + WpSpaJson *info = NULL; + + /* Parse global Id */ + if (!spa_atou32 (key, &global_id, 10)) { + wp_warning_object (self, + "Failed to parse global Id from metadata key '%s'. Ignoring...", + key); + continue; + } + + /* Check if value is valid */ + if (value) { + g_autoptr (WpSpaJson) json_val = wp_spa_json_new_from_string (value); + gboolean val = FALSE; + if (!wp_spa_json_is_boolean (json_val) || + !wp_spa_json_parse_boolean (json_val, &val) || !val) { + wp_warning_object (self, + "Metadata value '%s' is not a JSON boolean value set to TRUE. " + "Ignoring change...", value); + continue; + } + } + + info = g_hash_table_lookup (self->globals_info, + GUINT_TO_POINTER (global_id)); + if (!info) { + WpSpaJson *new_info = wp_spa_json_new_boolean (TRUE); + g_hash_table_insert (self->globals_info, GUINT_TO_POINTER (global_id), + new_info); + } + } + + /* Set the metadata weak ref */ + g_weak_ref_set (&self->metadata, m); + + /* Set the loaded feature */ + wp_object_update_features (WP_OBJECT (self), WP_COLLECTION_LOADED, 0); +} + +static void +wp_collection_activate_execute_step (WpObject * object, + WpFeatureActivationTransition * transition, guint step, + WpObjectFeatures missing) +{ + WpCollection *self = WP_COLLECTION (object); + g_autoptr (WpCore) core = wp_object_get_core (object); + + switch (step) { + case STEP_LOAD: { + g_clear_object (&self->metadata_om); + self->metadata_om = wp_object_manager_new (); + + wp_object_manager_add_interest (self->metadata_om, WP_TYPE_METADATA, + WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, + "metadata.name", "=s", self->name, + WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, + "wireplumber.collection", "=b", TRUE, + NULL); + wp_object_manager_request_object_features (self->metadata_om, + WP_TYPE_METADATA, WP_OBJECT_FEATURES_ALL); + g_signal_connect_object (self->metadata_om, "object-added", + G_CALLBACK (on_metadata_added), transition, 0); + wp_core_install_object_manager (core, self->metadata_om); + + wp_info_object (self, "looking for metadata object named %s", self->name); + break; + } + + case WP_TRANSITION_STEP_ERROR: + break; + default: + g_assert_not_reached (); + } +} + +static void +wp_collection_deactivate (WpObject * object, WpObjectFeatures features) +{ + WpCollection *self = WP_COLLECTION (object); + + g_clear_object (&self->metadata_om); + g_weak_ref_set (&self->metadata, NULL); + g_hash_table_remove_all (self->globals_info); + + wp_object_update_features (WP_OBJECT (self), 0, WP_OBJECT_FEATURES_ALL); +} + +static void +wp_collection_finalize (GObject * object) +{ + WpCollection *self = WP_COLLECTION (object); + + g_clear_object (&self->metadata_om); + g_weak_ref_clear (&self->metadata); + g_clear_pointer (&self->globals_info, g_hash_table_unref); + g_clear_pointer (&self->name, g_free); + + G_OBJECT_CLASS (wp_collection_parent_class)->finalize (object); +} + +static void +wp_collection_class_init (WpCollectionClass * klass) +{ + GObjectClass * object_class = (GObjectClass *) klass; + WpObjectClass *wpobject_class = (WpObjectClass *) klass; + + object_class->finalize = wp_collection_finalize; + object_class->set_property = wp_collection_set_property; + object_class->get_property = wp_collection_get_property; + + wpobject_class->get_supported_features = wp_collection_get_supported_features; + wpobject_class->activate_get_next_step = wp_collection_activate_get_next_step; + wpobject_class->activate_execute_step = wp_collection_activate_execute_step; + wpobject_class->deactivate = wp_collection_deactivate; + + g_object_class_install_property (object_class, PROP_NAME, + g_param_spec_string ("name", "name", + "The collection name", NULL, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); +} + +/*! + * \brief Creates a new WpCollection object + * + * \ingroup wpcollection + * \param core the WpCore + * \param name: the name of the collection + * \returns (transfer full): a new WpCollection object + */ +WpCollection * +wp_collection_new (WpCore * core, const gchar * name) +{ + g_return_val_if_fail (core, NULL); + g_return_val_if_fail (name, NULL); + + return g_object_new (WP_TYPE_COLLECTION, "core", core, "name", name, NULL); +} + +/*! + * \brief Gets the name of the collection + * + * \ingroup wpcollection + * \param self the WpCollection + * \returns (transfer none): the name of the collection + */ +const gchar * +wp_collection_get_name (WpCollection *self) +{ + g_return_val_if_fail (WP_IS_COLLECTION (self), NULL); + + return self->name; +} + +/*! + * \brief Gets the metadata of the collection + * + * \ingroup wpcollection + * \param self the WpCollection + * \returns (nullable) (transfer full): the metadata of the collection + */ +WpMetadata * +wp_collection_get_metadata (WpCollection *self) +{ + g_return_val_if_fail (WP_IS_COLLECTION (self), NULL); + + return g_weak_ref_get (&self->metadata); +} + +/*! + * \brief Adds a global into the collection + * + * \ingroup wpcollection + * \param self the WpCollection + * \param global (transfer none): the global to collect into the collection + * \returns TRUE if the global was collected, FALSE otherwise. + */ +gboolean +wp_collection_collect_global (WpCollection *self, WpGlobalProxy *global) +{ + g_autoptr (WpMetadata) m = NULL; + g_autofree gchar *key = NULL; + g_autoptr (WpSpaJson) json_value = NULL; + guint32 bound_id; + + g_return_val_if_fail (WP_IS_COLLECTION (self), FALSE); + g_return_val_if_fail (WP_IS_GLOBAL_PROXY (global), FALSE); + g_return_val_if_fail (wp_object_test_active_features (WP_OBJECT (global), + WP_PROXY_FEATURE_BOUND), FALSE); + + m = g_weak_ref_get (&self->metadata); + if (!m) + return FALSE; + + if (wp_collection_contains_global (self, global)) + return TRUE; + + bound_id = wp_proxy_get_bound_id (WP_PROXY (global)); + json_value = wp_spa_json_new_boolean (TRUE); + key = g_strdup_printf ("%u", bound_id); + + g_hash_table_insert (self->globals_info, GUINT_TO_POINTER (bound_id), + wp_spa_json_ref (json_value)); + wp_metadata_set (m, 0, key, "Spa:String:JSON", + wp_spa_json_get_data (json_value)); + return TRUE; +} + +/*! + * \brief Drops a global from the collection + * + * \ingroup wpcollection + * \param self the WpCollection + * \param global (transfer none): the global to drop from the collection + * \returns TRUE if the global was dropped, FALSE otherwise. + */ +gboolean +wp_collection_drop_global (WpCollection *self, WpGlobalProxy *global) +{ + g_autoptr (WpMetadata) m = NULL; + g_autofree gchar *key = NULL; + guint32 bound_id; + + g_return_val_if_fail (WP_IS_COLLECTION (self), FALSE); + g_return_val_if_fail (WP_IS_GLOBAL_PROXY (global), FALSE); + g_return_val_if_fail (wp_object_test_active_features (WP_OBJECT (global), + WP_PROXY_FEATURE_BOUND), FALSE); + + bound_id = wp_proxy_get_bound_id (WP_PROXY (global)); + + m = g_weak_ref_get (&self->metadata); + if (!m) + return FALSE; + + g_hash_table_remove (self->globals_info, GUINT_TO_POINTER (bound_id)); + key = g_strdup_printf ("%u", bound_id); + wp_metadata_set (m, 0, key, NULL, NULL); + return TRUE; +} + +/*! + * \brief Checks whether the collection contains a global or not + * + * \ingroup wpcollection + * \param self the WpCollection + * \param global (transfer none): the global to check + * \returns TRUE if the collection contains the global, FALSE otherwise. + */ +gboolean +wp_collection_contains_global (WpCollection *self, WpGlobalProxy *global) +{ + guint32 bound_id; + + g_return_val_if_fail (WP_IS_COLLECTION (self), FALSE); + g_return_val_if_fail (WP_IS_GLOBAL_PROXY (global), FALSE); + g_return_val_if_fail (wp_object_test_active_features (WP_OBJECT (global), + WP_PROXY_FEATURE_BOUND), FALSE); + + bound_id = wp_proxy_get_bound_id (WP_PROXY (global)); + return g_hash_table_contains (self->globals_info, + GUINT_TO_POINTER (bound_id)); +} + +/*! + * \brief Gets the total number of globals that the collection has + * + * \ingroup wpcollection + * \param self the WpCollection + * \returns the total number of globals the collection has + */ +guint +wp_collection_get_global_count (WpCollection *self) +{ + g_return_val_if_fail (WP_IS_COLLECTION (self), 0); + + return g_hash_table_size (self->globals_info); +} diff --git a/lib/wp/collection.h b/lib/wp/collection.h new file mode 100644 index 00000000..84d667f9 --- /dev/null +++ b/lib/wp/collection.h @@ -0,0 +1,49 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#ifndef __WIREPLUMBER_COLLECTION_H__ +#define __WIREPLUMBER_COLLECTION_H__ + +#include "metadata.h" + +G_BEGIN_DECLS + +/*! + * \brief Flags to be used as WpObjectFeatures for WpCollection. + * \ingroup wpcollection + */ +typedef enum { /*< flags >*/ + /*! Loads the collection */ + WP_COLLECTION_LOADED = (1 << 0), +} WpCollectionFeatures; + +/*! + * \brief The WpCollection GType + * \ingroup wpcollection + */ +#define WP_TYPE_COLLECTION (wp_collection_get_type ()) + +WP_API +G_DECLARE_FINAL_TYPE (WpCollection, wp_collection, WP, COLLECTION, WpObject) + +WP_API +const gchar * wp_collection_get_name (WpCollection *self); + +WP_API +WpMetadata *wp_collection_get_metadata (WpCollection *self); + +WP_API +gboolean wp_collection_contains_global (WpCollection *self, + WpGlobalProxy *global); + +WP_API +guint wp_collection_get_global_count (WpCollection *self); + +G_END_DECLS + +#endif diff --git a/lib/wp/meson.build b/lib/wp/meson.build index c3ff7473..092a9d13 100644 --- a/lib/wp/meson.build +++ b/lib/wp/meson.build @@ -1,6 +1,7 @@ wp_lib_sources = files( 'base-dirs.c', 'client.c', + 'collection.c', 'component-loader.c', 'conf.c', 'core.c', @@ -49,6 +50,7 @@ wp_lib_priv_sources = files( wp_lib_headers = files( 'base-dirs.h', 'client.h', + 'collection.h', 'component-loader.h', 'conf.h', 'core.h', diff --git a/lib/wp/private/collection.h b/lib/wp/private/collection.h new file mode 100644 index 00000000..1a09b839 --- /dev/null +++ b/lib/wp/private/collection.h @@ -0,0 +1,27 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#ifndef __WIREPLUMBER_PRIVATE_COLLECTION_H__ +#define __WIREPLUMBER_PRIVATE_COLLECTION_H__ + +#include "global-proxy.h" + +G_BEGIN_DECLS + +typedef struct _WpCollection WpCollection; + +WpCollection * wp_collection_new (WpCore * core, const gchar * name); + +gboolean wp_collection_collect_global (WpCollection *self, + WpGlobalProxy *global); + +gboolean wp_collection_drop_global (WpCollection *self, WpGlobalProxy *global); + +G_END_DECLS + +#endif diff --git a/lib/wp/wp.h b/lib/wp/wp.h index 7ef4e685..4169343a 100644 --- a/lib/wp/wp.h +++ b/lib/wp/wp.h @@ -48,6 +48,7 @@ #include "factory.h" #include "settings.h" #include "permission-manager.h" +#include "collection.h" G_BEGIN_DECLS From a3803de00b35e5af7b63eb9bd3d672b539cf43f9 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Fri, 5 Dec 2025 14:29:05 -0500 Subject: [PATCH 02/17] lib: Add new WpCollectionManager API This API allows handling of collections easily. It mainly allows creating them, destroying them, collect globals into a them and dropping glovals from them. The manager also automatically destroys a collection of all globals where dropped from the collection. --- lib/wp/collection-manager.c | 907 ++++++++++++++++++++++++++++++++++ lib/wp/collection-manager.h | 105 ++++ lib/wp/meson.build | 2 + lib/wp/wp.h | 1 + tests/wp/collection-manager.c | 446 +++++++++++++++++ tests/wp/meson.build | 7 + 6 files changed, 1468 insertions(+) create mode 100644 lib/wp/collection-manager.c create mode 100644 lib/wp/collection-manager.h create mode 100644 tests/wp/collection-manager.c diff --git a/lib/wp/collection-manager.c b/lib/wp/collection-manager.c new file mode 100644 index 00000000..a7e76534 --- /dev/null +++ b/lib/wp/collection-manager.c @@ -0,0 +1,907 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#include + +#include "error.h" +#include "core.h" +#include "collection-manager.h" +#include "metadata.h" +#include "log.h" +#include "object-manager.h" + +#include "private/collection.h" + +WP_DEFINE_LOCAL_LOG_TOPIC ("wp-collection-manager") + +/*! \defgroup wpcollectionmanager WpCollectionManager */ +/*! + * \struct WpCollectionManager + * + * The WpCollectionManager class is the object charged to create and destroy + * the collections in WirePlumber. + * + * WpCollection API. + */ + +struct _WpCollectionManager +{ + WpObject parent; + + /* Props */ + gchar *metadata_name; + + /* Metadata */ + WpObjectManager *metadata_om; + GWeakRef metadata; + + /* Collections info */ + guint pending_collections; + guint failed_collections; + GHashTable *collections; + GHashTable *create_collection_tasks; + + /* Globals info */ + WpObjectManager *global_proxy_om; + GHashTable *collected_globals; +}; + +enum { + PROP_0, + PROP_METADATA_NAME, +}; + +enum { + SIGNAL_GLOBAL_COLLECTED, + SIGNAL_GLOBAL_DROPPED, + LAST_SIGNAL, +}; + +static guint signals[LAST_SIGNAL] = { 0 }; + +G_DEFINE_TYPE (WpCollectionManager, wp_collection_manager, WP_TYPE_OBJECT) + +static void +wp_collection_manager_init (WpCollectionManager * self) +{ + self->collections = g_hash_table_new_full (g_str_hash, g_str_equal, + g_free, g_object_unref); + self->create_collection_tasks = g_hash_table_new_full (g_str_hash, + g_str_equal, g_free, g_object_unref); + self->collected_globals = g_hash_table_new_full (g_direct_hash, + g_direct_equal, NULL, g_free); +} + +static void +wp_collection_manager_set_property (GObject * object, guint property_id, + const GValue * value, GParamSpec * pspec) +{ + WpCollectionManager *self = WP_COLLECTION_MANAGER (object); + + switch (property_id) { + case PROP_METADATA_NAME: + g_clear_pointer (&self->metadata_name, g_free); + self->metadata_name = g_value_dup_string (value); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +static void +wp_collection_manager_get_property (GObject * object, guint property_id, + GValue * value, GParamSpec * pspec) +{ + WpCollectionManager *self = WP_COLLECTION_MANAGER (object); + + switch (property_id) { + case PROP_METADATA_NAME: + g_value_set_string (value, self->metadata_name); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +enum { + STEP_LOAD = WP_TRANSITION_STEP_CUSTOM_START, +}; + +static WpObjectFeatures +wp_collection_manager_get_supported_features (WpObject * self) +{ + return WP_COLLECTION_MANAGER_LOADED; +} + +static guint +wp_collection_manager_activate_get_next_step (WpObject * self, + WpFeatureActivationTransition * transition, guint step, + WpObjectFeatures missing) +{ + g_return_val_if_fail (missing == WP_COLLECTION_MANAGER_LOADED, + WP_TRANSITION_STEP_ERROR); + + return STEP_LOAD; +} + +static void +on_global_proxy_removed (WpObjectManager *om, WpGlobalProxy *global, gpointer d) +{ + WpCollectionManager * self = WP_COLLECTION_MANAGER (d); + + /* Drop global from the collection if global is removed */ + wp_collection_manager_drop_global (self, global); +} + +static void +on_collection_activated (GObject * o, GAsyncResult *res, + WpTransition * transition) +{ + WpCollectionManager * self = wp_transition_get_source_object (transition); + g_autoptr (WpCollection) collection = WP_COLLECTION (o); + g_autoptr (GError) error = NULL; + const gchar *name; + + /* Get the task name */ + name = wp_collection_get_name (collection); + g_return_if_fail (name); + + /* Add collection to hash table if it was activated successfully */ + if (!wp_object_activate_finish (WP_OBJECT (collection), res, &error)) { + self->failed_collections++; + wp_warning_object (self, "Failed to activate collection '%s': %s", name, + error->message); + } else { + g_autoptr (WpMetadata) m = wp_collection_get_metadata (collection); + g_autoptr (WpIterator) it = NULL; + g_auto (GValue) item = G_VALUE_INIT; + + /* Collect the globals */ + it = wp_metadata_new_iterator (m, PW_ID_ANY); + for (; wp_iterator_next (it, &item); g_value_unset (&item)) { + WpMetadataItem *mi = g_value_get_boxed (&item); + const gchar *key = wp_metadata_item_get_key (mi); + guint32 global_id = SPA_ID_INVALID; + + /* Parse global Id */ + if (!spa_atou32 (key, &global_id, 10)) { + wp_warning_object (self, + "Failed to parse global Id from metadata key '%s'. Ignoring...", + key); + continue; + } + + g_autoptr (WpGlobalProxy) global = wp_object_manager_lookup ( + self->global_proxy_om, WP_TYPE_GLOBAL_PROXY, + WP_CONSTRAINT_TYPE_G_PROPERTY, "bound-id", "=u", global_id, NULL); + if (global) + g_hash_table_insert (self->collected_globals, global, g_strdup (name)); + else + wp_warning_object (self, + "Could not find global with Id %u for collection %s. Not adding...", + global_id, name); + } + + /* Add the collection */ + g_hash_table_insert (self->collections, g_strdup (name), + g_object_ref (collection)); + wp_info_object (self, "Added collection '%s' successfully", name); + } + + g_return_if_fail (self->pending_collections > 0); + self->pending_collections--; + + /* Set loaded feature if no pending collections */ + if (self->pending_collections == 0) { + /* Check for errors */ + if (self->failed_collections > 0) { + wp_transition_return_error (transition, g_error_new ( + WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_OPERATION_FAILED, + "%d collections failed to activate", + self->failed_collections)); + } else { + wp_object_update_features (WP_OBJECT (self), WP_COLLECTION_MANAGER_LOADED, 0); + } + } +} + +static void +on_collection_ready (GObject * o, GAsyncResult *res, gpointer d) +{ + WpCollectionManager * self = WP_COLLECTION_MANAGER (d); + g_autoptr (WpCollection) collection = WP_COLLECTION (o); + g_autoptr (GError) error = NULL; + g_autoptr (GTask) t = NULL; + g_autofree gchar *k = NULL; + const gchar *name; + + /* Get the task name */ + name = wp_collection_get_name (collection); + g_return_if_fail (name); + + /* Get the pending task if any */ + g_hash_table_steal_extended (self->create_collection_tasks, name, + (gpointer *)&k, (gpointer *)&t); + + /* Add collection to hash table if it was activated successfully */ + name = wp_collection_get_name (collection); + if (!wp_object_activate_finish (WP_OBJECT (collection), res, &error)) { + wp_warning_object (self, "Failed to activate collection '%s': %s", name, + error->message); + if (t) + g_task_return_new_error (t, WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_INVARIANT, + "Could not add collection '%s' as its activation failed: %s", name, + error->message); + return; + } + + /* Add collection to the hash table */ + g_hash_table_insert (self->collections, g_strdup (name), + g_object_ref (collection)); + wp_info_object (self, "Added collection '%s' successfully", name); + + /* Notify pending task */ + if (t) + g_task_return_pointer (t, collection, NULL); +} + +static void +on_metadata_changed (WpMetadata *m, guint32 subject, + const gchar *key, const gchar *type, const gchar *value, gpointer d) +{ + WpCollectionManager *self = WP_COLLECTION_MANAGER (d); + g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self)); + + if (key) { + if (value) { + WpCollection *collection; + wp_info_object (self, "Adding collection '%s'", key); + collection = wp_collection_new (core, key); + wp_object_activate (WP_OBJECT (collection), WP_OBJECT_FEATURES_ALL, NULL, + on_collection_ready, self); + } else { + g_hash_table_remove (self->collections, key); + wp_info_object (self, "Removed collection '%s'", key); + } + } else { + g_hash_table_remove_all (self->collections); + wp_info_object (self, "Removed all collections"); + } +} + +static void +on_metadata_added (WpObjectManager *om, WpMetadata *m, gpointer d) +{ + WpTransition * transition = WP_TRANSITION (d); + WpCollectionManager * self = wp_transition_get_source_object (transition); + g_autoptr (WpProperties) props = NULL; + const gchar *metadata_name = NULL; + g_autoptr (WpCore) core = NULL; + g_autoptr (WpIterator) it = NULL; + g_auto (GValue) item = G_VALUE_INIT; + + /* Make sure the metadata has a name */ + props = wp_global_proxy_get_global_properties (WP_GLOBAL_PROXY (m)); + g_return_if_fail (props); + metadata_name = wp_properties_get (props, "metadata.name"); + g_return_if_fail (metadata_name); + g_return_if_fail (g_str_equal (metadata_name, self->metadata_name)); + + /* Set the metadata weak ref */ + g_weak_ref_set (&self->metadata, m); + + /* Listen for metadata changes */ + g_signal_connect_object (m, "changed", G_CALLBACK (on_metadata_changed), + self, 0); + + /* Check if there are collections, and activate them if so */ + self->pending_collections = 0; + self->failed_collections = 0; + core = wp_object_get_core (WP_OBJECT (self)); + it = wp_metadata_new_iterator (m, 0); + for (; wp_iterator_next (it, &item); g_value_unset (&item)) { + WpMetadataItem *mi = g_value_get_boxed (&item); + const gchar *key = wp_metadata_item_get_key (mi); + WpCollection *collection = wp_collection_new (core, key); + self->pending_collections++; + wp_object_activate_closure (WP_OBJECT (collection), + WP_OBJECT_FEATURES_ALL, NULL, + g_cclosure_new_object ( + (GCallback) on_collection_activated, G_OBJECT (transition))); + } + + /* Otherwise just set loaded feature */ + if (self->pending_collections == 0) + wp_object_update_features (WP_OBJECT (self), WP_COLLECTION_MANAGER_LOADED, 0); +} + +static void +on_global_proxy_om_installed (WpObjectManager *om, gpointer d) +{ + WpTransition * transition = WP_TRANSITION (d); + WpCollectionManager * self = wp_transition_get_source_object (transition); + g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self)); + + /* Install metadata object manager */ + g_clear_object (&self->metadata_om); + self->metadata_om = wp_object_manager_new (); + wp_object_manager_add_interest (self->metadata_om, WP_TYPE_METADATA, + WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, + "metadata.name", "=s", self->metadata_name, + WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, + "wireplumber.collection-manager", "=b", TRUE, + NULL); + wp_object_manager_request_object_features (self->metadata_om, + WP_TYPE_METADATA, WP_OBJECT_FEATURES_ALL); + g_signal_connect_object (self->metadata_om, "object-added", + G_CALLBACK (on_metadata_added), transition, 0); + wp_core_install_object_manager (core, self->metadata_om); + + wp_info_object (self, "looking for metadata object named %s", + self->metadata_name); +} + +static void +wp_collection_manager_activate_execute_step (WpObject * object, + WpFeatureActivationTransition * transition, guint step, + WpObjectFeatures missing) +{ + WpCollectionManager *self = WP_COLLECTION_MANAGER (object); + g_autoptr (WpCore) core = wp_object_get_core (object); + + switch (step) { + case STEP_LOAD: { + /* Install global proxy object manager */ + g_clear_object (&self->global_proxy_om); + self->global_proxy_om = wp_object_manager_new (); + wp_object_manager_add_interest (self->global_proxy_om, + WP_TYPE_GLOBAL_PROXY, NULL); + wp_object_manager_request_object_features (self->global_proxy_om, + WP_TYPE_GLOBAL_PROXY, WP_PIPEWIRE_OBJECT_FEATURES_MINIMAL); + g_signal_connect_object (self->global_proxy_om, "object-removed", + G_CALLBACK (on_global_proxy_removed), self, 0); + g_signal_connect_object (self->global_proxy_om, "installed", + G_CALLBACK (on_global_proxy_om_installed), transition, 0); + wp_core_install_object_manager (core, self->global_proxy_om); + break; + } + case WP_TRANSITION_STEP_ERROR: + break; + default: + g_assert_not_reached (); + } +} + +static void +finish_task (gpointer key, gpointer value, gpointer user_data) +{ + const char *collection_name = key; + GTask *t = G_TASK (value); + g_task_return_new_error (t, WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_INVARIANT, + "Deactivated collection manager before collection '%s' was created", + collection_name); +} + +static void +wp_collection_manager_deactivate (WpObject * object, WpObjectFeatures features) +{ + WpCollectionManager *self = WP_COLLECTION_MANAGER (object); + + /* Globals info */ + g_hash_table_remove_all (self->collected_globals); + g_clear_object (&self->global_proxy_om); + + /* Collections info */ + g_hash_table_foreach (self->create_collection_tasks, finish_task, NULL); + g_hash_table_remove_all (self->create_collection_tasks); + g_hash_table_remove_all (self->collections); + + /* Metadata */ + g_weak_ref_set (&self->metadata, NULL); + g_clear_object (&self->metadata_om); + + wp_object_update_features (WP_OBJECT (self), 0, WP_OBJECT_FEATURES_ALL); +} + +static void +wp_collection_manager_finalize (GObject * object) +{ + WpCollectionManager *self = WP_COLLECTION_MANAGER (object); + + /* Globals info */ + g_clear_pointer (&self->collected_globals, g_hash_table_unref); + g_clear_object (&self->global_proxy_om); + + /* Collections info */ + g_hash_table_foreach (self->create_collection_tasks, finish_task, NULL); + g_clear_pointer (&self->create_collection_tasks, g_hash_table_unref); + g_clear_pointer (&self->collections, g_hash_table_unref); + + /* Metadata */ + g_weak_ref_clear (&self->metadata); + g_clear_object (&self->metadata_om); + + /* Props */ + g_clear_pointer (&self->metadata_name, g_free); + + G_OBJECT_CLASS (wp_collection_manager_parent_class)->finalize (object); +} + +static void +wp_collection_manager_class_init (WpCollectionManagerClass * klass) +{ + GObjectClass * object_class = (GObjectClass *) klass; + WpObjectClass *wpobject_class = (WpObjectClass *) klass; + + object_class->finalize = wp_collection_manager_finalize; + object_class->set_property = wp_collection_manager_set_property; + object_class->get_property = wp_collection_manager_get_property; + + wpobject_class->get_supported_features = + wp_collection_manager_get_supported_features; + wpobject_class->activate_get_next_step = + wp_collection_manager_activate_get_next_step; + wpobject_class->activate_execute_step = + wp_collection_manager_activate_execute_step; + wpobject_class->deactivate = wp_collection_manager_deactivate; + + g_object_class_install_property (object_class, PROP_METADATA_NAME, + g_param_spec_string ("metadata-name", "metadata-name", + "The metadata object to look after", NULL, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); + + signals[SIGNAL_GLOBAL_COLLECTED] = g_signal_new ( + "global-collected", G_TYPE_FROM_CLASS (klass), G_SIGNAL_RUN_FIRST, + 0, NULL, NULL, NULL, G_TYPE_NONE, 2, WP_TYPE_GLOBAL_PROXY, G_TYPE_STRING); + + signals[SIGNAL_GLOBAL_DROPPED] = g_signal_new ( + "global-dropped", G_TYPE_FROM_CLASS (klass), G_SIGNAL_RUN_FIRST, + 0, NULL, NULL, NULL, G_TYPE_NONE, 2, WP_TYPE_GLOBAL_PROXY, G_TYPE_STRING); +} + +/*! + * \brief Creates a new WpCollectionManager object + * + * \ingroup wpcollectionmanager + * \param core the WpCore + * \param metadata_name (nullable): the name of the metadata object to + * associate with the collection manager object; NULL means the default + * "sm-collection-manager" + * \returns (transfer full): a new WpCollectionManager object + */ +WpCollectionManager * +wp_collection_manager_new (WpCore * core, const gchar * metadata_name) +{ + return g_object_new (WP_TYPE_COLLECTION_MANAGER, + "core", core, + "metadata-name", metadata_name ? metadata_name : "sm-collection-manager", + NULL); +} + +static gboolean +find_collection_manager_func (gpointer g_object, gpointer metadata_name) +{ + if (!WP_IS_COLLECTION_MANAGER (g_object)) + return FALSE; + + if (metadata_name) + return g_str_equal (((WpCollectionManager *) g_object)->metadata_name, + (gchar *) metadata_name); + else + return TRUE; +} + +/*! + * \brief Finds a registered WpCollectionManager object by its metadata name + * + * \ingroup wpcollectionmanager + * \param core the WpCore + * \param metadata_name (nullable): the name of the metadata object that the + * collection manager object is associated with; NULL returns the first + * collection manager object that is found + * \returns (transfer full) (nullable): the WpCollectionManager object, or NULL + * if not found + */ +WpCollectionManager * +wp_collection_manager_find (WpCore * core, const gchar *metadata_name) +{ + g_return_val_if_fail (WP_IS_CORE (core), NULL); + + GObject *cm = wp_core_find_object (core, + (GEqualFunc) find_collection_manager_func, metadata_name); + return cm ? WP_COLLECTION_MANAGER (cm) : NULL; +} + +/*! + * \brief Callback version of wp_collection_manager_create_collection_closure() + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param collection_name the new collection name to create + * \param cancellable (nullable): a cancellable for the async operation + * \param callback (scope async)(closure user_data): a function to call when + * the collection was created + * \param user_data data for \a callback + */ +void +wp_collection_manager_create_collection (WpCollectionManager *self, + const gchar *collection_name, GCancellable * cancellable, + GAsyncReadyCallback callback, gpointer user_data) +{ + g_return_if_fail (WP_IS_COLLECTION_MANAGER (self)); + + GClosure *closure = g_cclosure_new (G_CALLBACK (callback), user_data, NULL); + + wp_collection_manager_create_collection_closure (self, collection_name, + cancellable, closure); +} + +static void +invoke_closure (GObject * obj, GAsyncResult * res, gpointer data) +{ + GClosure *closure = data; + GValue values[2] = { G_VALUE_INIT, G_VALUE_INIT }; + g_value_init (&values[0], G_TYPE_OBJECT); + g_value_init (&values[1], G_TYPE_OBJECT); + g_value_set_object (&values[0], obj); + g_value_set_object (&values[1], res); + g_closure_invoke (closure, NULL, 2, values, NULL); + g_value_unset (&values[0]); + g_value_unset (&values[1]); + g_closure_unref (closure); +} + +/*! + * \brief Creates a new collection into the manager + * + * \note \a closure may be invoked in sync while this method is being called, + * if the collection already exists. + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param collection_name the new collection name to create + * \param cancellable (nullable): a cancellable for the async operation + * \param closure (transfer full): the closure to use when the collection was + * created + */ +void +wp_collection_manager_create_collection_closure (WpCollectionManager *self, + const gchar *collection_name, GCancellable * cancellable, GClosure *closure) +{ + g_autoptr (GTask) t = NULL; + g_autoptr (WpMetadata) m = NULL; + WpCollection *c = NULL; + g_autoptr (WpSpaJson) json_value = NULL; + + g_return_if_fail (WP_IS_COLLECTION_MANAGER (self)); + + /* Create the task from the closure */ + if (G_CLOSURE_NEEDS_MARSHAL (closure)) + g_closure_set_marshal (closure, g_cclosure_marshal_VOID__OBJECT); + t = g_task_new (self, cancellable, invoke_closure, closure); + + /* Just return if the collection already exists */ + c = g_hash_table_lookup (self->collections, collection_name); + if (c) { + g_task_return_pointer (t, c, NULL); + return; + } + + /* Make sure the new collection is not being created already */ + if (g_hash_table_contains (self->create_collection_tasks, collection_name)) { + g_task_return_new_error (t, WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_INVARIANT, + "Cannot create collection '%s' as another task is already creating it", + collection_name); + return; + } + + /* Get the metadata */ + m = g_weak_ref_get (&self->metadata); + if (!m) { + g_task_return_new_error (t, WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_INVARIANT, + "Collection manager metadata not found, cannot create collection '%s'", + collection_name); + return; + } + + /* Request new collection */ + g_hash_table_insert (self->create_collection_tasks, + g_strdup (collection_name), g_object_ref (t)); + json_value = wp_spa_json_new_boolean (TRUE); + wp_metadata_set (m, 0, collection_name, "Spa:String:JSON", + wp_spa_json_get_data (json_value)); +} + +/*! + * \brief Finishes the async operation that was started with + * wp_collection_manager_create_collection() + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param res the async operation result + * \param error (out) (optional): the error of the operation, if any + * \returns (nullable) (transfer none): The newly created WpCollection, or NULL + * if an error happened. + */ +WpCollection * +wp_collection_manager_create_collection_finish (WpCollectionManager * self, + GAsyncResult * res, GError ** error) +{ + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), FALSE); + g_return_val_if_fail (g_task_is_valid (res, self), FALSE); + + return g_task_propagate_pointer (G_TASK (res), error); +} + +/*! + * \brief Destroys a collection from the manager + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param collection_name the collection name to destroy + * \returns TRUE if the collection was destroyed, FALSE otherwise + */ +gboolean +wp_collection_manager_destroy_collection (WpCollectionManager *self, + const gchar *collection_name) +{ + g_autoptr (WpMetadata) m = NULL; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), FALSE); + + m = g_weak_ref_get (&self->metadata); + if (!m) + return FALSE; + + g_hash_table_remove (self->collections, collection_name); + wp_metadata_set (m, 0, collection_name, "Spa:String:JSON", NULL); + return TRUE; +} + +/*! + * \brief Checks whether the collection manager has a collection or not + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param collection_name the collection name to check + * \returns TRUE if the collection exists, FALSE otherwise + */ +gboolean +wp_collection_manager_has_collection (WpCollectionManager *self, + const gchar *collection_name) +{ + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), FALSE); + + return g_hash_table_contains (self->collections, collection_name); +} + +/*! + * \brief Get the collection for the given collection name + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param collection_name the collection name to get + * \returns (nullable) (transfer full): the collection for the given name + */ +WpCollection * +wp_collection_manager_get_collection (WpCollectionManager *self, + const gchar *collection_name) +{ + WpCollection *c = NULL; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), NULL); + + c = g_hash_table_lookup (self->collections, collection_name); + return c ? g_object_ref (c) : NULL; +} + +/*! + * \brief Gets the total number of collections the manager has + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \returns the total number of collections the manager has + */ +guint +wp_collection_manager_get_collection_count (WpCollectionManager *self) +{ + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), 0); + + return g_hash_table_size (self->collections); +} + +/*! + * \brief Iterates over all the collections that the manager has + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \returns (transfer full): an iterator that iterates over the collections + */ +WpIterator * +wp_collection_manager_new_collection_iterator (WpCollectionManager *self) +{ + GPtrArray *collections; + GHashTableIter iter; + WpCollection *c; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), NULL); + + collections = g_ptr_array_new_with_free_func (g_object_unref); + g_hash_table_iter_init (&iter, self->collections); + while (g_hash_table_iter_next (&iter, NULL, (gpointer *)&c)) + g_ptr_array_add (collections, g_object_ref (c)); + + return wp_iterator_new_ptr_array (collections, WP_TYPE_COLLECTION); +} + +/*! + * \brief Collects the bound global into a collection. + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param global (transfer none): the global to collect + * \param collection_name: the name of the collection to collect the global into + * \returns TRUE if the global could be collected, FALSE otherwise + */ +gboolean +wp_collection_manager_collect_global (WpCollectionManager *self, + WpGlobalProxy *global, const gchar *collection_name) +{ + const gchar *cn; + WpCollection *c; + g_autoptr (WpMetadata) c_meta = NULL; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), FALSE); + g_return_val_if_fail (wp_object_test_active_features ( + WP_OBJECT (global), WP_PROXY_FEATURE_BOUND), FALSE); + g_return_val_if_fail (collection_name, FALSE); + + /* Just return if this global is already collected */ + cn = g_hash_table_lookup (self->collected_globals, global); + if (cn) + return g_str_equal (cn, collection_name); + + /* Get the collection */ + c = g_hash_table_lookup (self->collections, collection_name); + if (!c) + return FALSE; + c_meta = wp_collection_get_metadata (c); + g_return_val_if_fail (c_meta, FALSE); + + /* Make sure we are not collecting a collection into itself */ + if (WP_GLOBAL_PROXY (c_meta) == global) { + wp_warning_object (self, "Cannot collect a collection into itself"); + return FALSE; + } + + if (!wp_collection_collect_global (c, global)) + return FALSE; + + g_hash_table_insert (self->collected_globals, global, + g_strdup (collection_name)); + + g_signal_emit (self, signals[SIGNAL_GLOBAL_COLLECTED], 0, global, + collection_name); + return TRUE; +} + +/*! + * \brief Drops the bound global from its collection + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param global (transfer none): the global to drop + * \returns TRUE if the global could be dropped, FALSE otherwise + */ +gboolean +wp_collection_manager_drop_global (WpCollectionManager *self, + WpGlobalProxy *global) +{ + g_autofree gchar *cn = NULL; + WpCollection *c; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), FALSE); + g_return_val_if_fail (wp_object_test_active_features ( + WP_OBJECT (global), WP_PROXY_FEATURE_BOUND), FALSE); + + /* Just return if this global is not collected */ + if (!g_hash_table_steal_extended (self->collected_globals, global, NULL, + (gpointer *)&cn)) + return TRUE; + + /* Get the collection */ + c = g_hash_table_lookup (self->collections, cn); + g_return_val_if_fail (c, FALSE); + + if (!wp_collection_drop_global (c, global)) + return FALSE; + + /* Destroy the collection if it is empty */ + if (wp_collection_get_global_count (c) == 0) + wp_collection_manager_destroy_collection (self, cn); + + g_signal_emit (self, signals[SIGNAL_GLOBAL_DROPPED], 0, global, cn); + return TRUE; +} + +/*! + * \brief Gets the collection that a given global belongs to + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param global (transfer none): the global to get the collection from + * \returns (nullable) (transfer full): the collection that the given global + * belongs too + */ +WpCollection * +wp_collection_manager_get_global_collection (WpCollectionManager *self, + WpGlobalProxy *global) +{ + const gchar *cn; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), NULL); + + cn = g_hash_table_lookup (self->collected_globals, global); + return cn ? wp_collection_manager_get_collection (self, cn) : NULL; +} + +/*! + * \brief Convenience method to checks whether a global is collected into a + * collection or not + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param global (transfer none): the global to check if it is collected or not + * \param collection_name (nullable): the collection name + * \returns TRUE if the global is collected, FALSE otherwise + * belongs too + */ +gboolean +wp_collection_manager_is_global_collected (WpCollectionManager *self, + WpGlobalProxy *global, const gchar *collection_name) +{ + const gchar *cn; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), FALSE); + + cn = g_hash_table_lookup (self->collected_globals, global); + if (!cn) + return FALSE; + + return collection_name ? g_str_equal (cn, collection_name) : TRUE; +} + +/*! + * \brief Iterates over all globals that a collection has + * + * \ingroup wpcollection + * \param self the WpCollection + * \param collection_name (nullable): the collection name + * \returns (transfer full): an iterator that iterates over the globals. + */ +WpIterator * +wp_collection_manager_new_global_iterator (WpCollectionManager *self, + const gchar *collection_name) +{ + GPtrArray *globals; + GHashTableIter iter; + WpGlobalProxy *g = NULL; + const gchar *cn = NULL; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), NULL); + + globals = g_ptr_array_new_with_free_func (g_object_unref); + g_hash_table_iter_init (&iter, self->collected_globals); + while (g_hash_table_iter_next (&iter, (gpointer *)&g, (gpointer *)&cn)) + if (!collection_name || g_str_equal (cn, collection_name)) + g_ptr_array_add (globals, g_object_ref (g)); + + return wp_iterator_new_ptr_array (globals, WP_TYPE_GLOBAL_PROXY); +} diff --git a/lib/wp/collection-manager.h b/lib/wp/collection-manager.h new file mode 100644 index 00000000..e30294b2 --- /dev/null +++ b/lib/wp/collection-manager.h @@ -0,0 +1,105 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#ifndef __WIREPLUMBER_COLLECTION_MANAGER_H__ +#define __WIREPLUMBER_COLLECTION_MANAGER_H__ + +#include "object.h" +#include "collection.h" + +G_BEGIN_DECLS + +/*! + * \brief Flags to be used as WpObjectFeatures for WpCollectionManager. + * \ingroup wpcollectionmanager + */ +typedef enum { /*< flags >*/ + /*! Loads the collection manager */ + WP_COLLECTION_MANAGER_LOADED = (1 << 0), +} WpCollectionManagerFeatures; + +/*! + * \brief The WpCollectionManager GType + * \ingroup wpcollectionmanager + */ +#define WP_TYPE_COLLECTION_MANAGER (wp_collection_manager_get_type ()) + +WP_API +G_DECLARE_FINAL_TYPE (WpCollectionManager, wp_collection_manager, WP, + COLLECTION_MANAGER, WpObject) + +WP_API +WpCollectionManager * wp_collection_manager_new (WpCore * core, + const gchar * metadata_name); + +WP_API +WpCollectionManager * wp_collection_manager_find (WpCore * core, + const gchar *metadata_name); + + +/* Collection */ + +WP_API +void wp_collection_manager_create_collection (WpCollectionManager *self, + const gchar *collection_name, GCancellable * cancellable, + GAsyncReadyCallback callback, gpointer user_data); + +WP_API +void wp_collection_manager_create_collection_closure (WpCollectionManager *self, + const gchar *collection_name, GCancellable * cancellable, + GClosure *closure); + +WP_API +WpCollection *wp_collection_manager_create_collection_finish ( + WpCollectionManager * self, GAsyncResult * res, GError ** error); + +WP_API +gboolean wp_collection_manager_destroy_collection (WpCollectionManager *self, + const gchar *collection_name); + +WP_API +gboolean wp_collection_manager_has_collection (WpCollectionManager *self, + const gchar *collection_name); + +WP_API +WpCollection * wp_collection_manager_get_collection ( + WpCollectionManager *self, const gchar *collection_name); + +WP_API +guint wp_collection_manager_get_collection_count (WpCollectionManager *self); + +WP_API +WpIterator * wp_collection_manager_new_collection_iterator ( + WpCollectionManager *self); + + +/* Global */ + +WP_API +gboolean wp_collection_manager_collect_global (WpCollectionManager *self, + WpGlobalProxy *global, const gchar *collection_name); + +WP_API +gboolean wp_collection_manager_drop_global (WpCollectionManager *self, + WpGlobalProxy *global); + +WP_API +WpCollection * wp_collection_manager_get_global_collection ( + WpCollectionManager *self, WpGlobalProxy *global); + +WP_API +gboolean wp_collection_manager_is_global_collected (WpCollectionManager *self, + WpGlobalProxy *global, const gchar *collection_name); + +WP_API +WpIterator * wp_collection_manager_new_global_iterator ( + WpCollectionManager *self, const gchar *collection_name); + +G_END_DECLS + +#endif diff --git a/lib/wp/meson.build b/lib/wp/meson.build index 092a9d13..6c01a515 100644 --- a/lib/wp/meson.build +++ b/lib/wp/meson.build @@ -2,6 +2,7 @@ wp_lib_sources = files( 'base-dirs.c', 'client.c', 'collection.c', + 'collection-manager.c', 'component-loader.c', 'conf.c', 'core.c', @@ -51,6 +52,7 @@ wp_lib_headers = files( 'base-dirs.h', 'client.h', 'collection.h', + 'collection-manager.h', 'component-loader.h', 'conf.h', 'core.h', diff --git a/lib/wp/wp.h b/lib/wp/wp.h index 4169343a..66f09c89 100644 --- a/lib/wp/wp.h +++ b/lib/wp/wp.h @@ -49,6 +49,7 @@ #include "settings.h" #include "permission-manager.h" #include "collection.h" +#include "collection-manager.h" G_BEGIN_DECLS diff --git a/tests/wp/collection-manager.c b/tests/wp/collection-manager.c new file mode 100644 index 00000000..6752297a --- /dev/null +++ b/tests/wp/collection-manager.c @@ -0,0 +1,446 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#include "../common/base-test-fixture.h" + +#define COLLECTION_A_NAME "sm-collection-a" +#define COLLECTION_B_NAME "sm-collection-b" +#define COLLECTION_C_NAME "sm-collection-c" +#define COLLECTION_MANAGER_METADATA_NAME "sm-collection-manager" + +typedef struct { + WpBaseTestFixture base; + WpImplMetadata *collection_a_metadata; + WpImplMetadata *collection_b_metadata; + WpImplMetadata *collection_manager_metadata; + WpCollectionManager *collection_manager; + WpCollection *collection_a; + WpCollection *collection_b; +} TestCollectionManagerFixture; + +static void +on_metadata_activated (WpMetadata * m, GAsyncResult * res, + gpointer d) +{ + TestCollectionManagerFixture *self = d; + g_assert_true (wp_object_activate_finish (WP_OBJECT (m), res, NULL)); + g_main_loop_quit (self->base.loop); +} + +static void +on_collection_manager_metadata_activated (WpMetadata * m, GAsyncResult * res, + gpointer d) +{ + TestCollectionManagerFixture *self = d; + g_assert_true (wp_object_activate_finish (WP_OBJECT (m), res, NULL)); + + /* Add collections A and B */ + wp_metadata_set (m, 0, COLLECTION_A_NAME, "Spa:String:JSON", "1"); + wp_metadata_set (m, 0, COLLECTION_B_NAME, "Spa:String:JSON", "1"); + + g_main_loop_quit (self->base.loop); +} + +static void +on_collection_manager_ready (WpCollectionManager *s, GAsyncResult *res, + gpointer d) +{ + TestCollectionManagerFixture *self = d; + + g_assert_true (wp_object_activate_finish (WP_OBJECT (s), res, NULL)); + + wp_core_register_object (self->base.core, g_object_ref (s)); + + g_main_loop_quit (self->base.loop); +} + +static void +collection_manager_setup (TestCollectionManagerFixture *self, gconstpointer d) +{ + wp_base_test_fixture_setup (&self->base, WP_BASE_TEST_FLAG_CLIENT_CORE); + + /* Create collection A metadata and activate it */ + self->collection_a_metadata = wp_impl_metadata_new_full ( + self->base.core, COLLECTION_A_NAME, + wp_properties_new ("wireplumber.collection", "true", NULL)); + wp_object_activate (WP_OBJECT (self->collection_a_metadata), + WP_OBJECT_FEATURES_ALL, NULL, + (GAsyncReadyCallback)on_metadata_activated, self); + g_main_loop_run (self->base.loop); + + /* Create collection B metadata and activate it */ + self->collection_b_metadata = wp_impl_metadata_new_full ( + self->base.core, COLLECTION_B_NAME, + wp_properties_new ("wireplumber.collection", "true", NULL)); + wp_object_activate (WP_OBJECT (self->collection_b_metadata), + WP_OBJECT_FEATURES_ALL, NULL, + (GAsyncReadyCallback)on_metadata_activated, self); + g_main_loop_run (self->base.loop); + + /* Create collection manager metadata and activate it */ + self->collection_manager_metadata = wp_impl_metadata_new_full ( + self->base.core, COLLECTION_MANAGER_METADATA_NAME, + wp_properties_new ("wireplumber.collection-manager", "true", NULL)); + wp_object_activate (WP_OBJECT (self->collection_manager_metadata), + WP_OBJECT_FEATURES_ALL, NULL, + (GAsyncReadyCallback)on_collection_manager_metadata_activated, self); + g_main_loop_run (self->base.loop); + + /* Create the collection manager and activate it */ + self->collection_manager = wp_collection_manager_new (self->base.core, + COLLECTION_MANAGER_METADATA_NAME); + wp_object_activate (WP_OBJECT (self->collection_manager), + WP_OBJECT_FEATURES_ALL, + NULL, + (GAsyncReadyCallback)on_collection_manager_ready, + self); + g_main_loop_run (self->base.loop); + + /* Get collections A and B */ + self->collection_a = wp_collection_manager_get_collection ( + self->collection_manager, COLLECTION_A_NAME); + g_assert_nonnull (self->collection_a); + self->collection_b = wp_collection_manager_get_collection ( + self->collection_manager, COLLECTION_B_NAME); + g_assert_nonnull (self->collection_b); +} + +static void +collection_manager_teardown (TestCollectionManagerFixture *self, + gconstpointer d) +{ + g_clear_object (&self->collection_b); + g_clear_object (&self->collection_a); + g_clear_object (&self->collection_manager); + g_clear_object (&self->collection_manager_metadata); + g_clear_object (&self->collection_b_metadata); + g_clear_object (&self->collection_a_metadata); + wp_base_test_fixture_teardown (&self->base); +} + +static void +on_collection_created (WpCollectionManager * cm, GAsyncResult * res, gpointer d) +{ + TestCollectionManagerFixture *self = d; + WpCollection *c = wp_collection_manager_create_collection_finish (cm, res, + NULL); + g_assert_nonnull (c); + g_assert_true (WP_IS_COLLECTION (c)); + g_main_loop_quit (self->base.loop); +} + +static void +test_collection_manager_collection (TestCollectionManagerFixture *self, + gconstpointer d) +{ + g_autoptr (WpImplMetadata) collection_c_metadata; + + /* Make sure the manager has 2 collections */ + g_assert_cmpuint (wp_collection_manager_get_collection_count ( + self->collection_manager), ==, 2); + + /* Make sure collections A and B were added */ + g_assert_true (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_A_NAME)); + g_assert_true (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_B_NAME)); + + /* Make sure collection C was not added */ + g_assert_false (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_C_NAME)); + + /* Create collection C metadata and activate it */ + collection_c_metadata = wp_impl_metadata_new_full ( + self->base.core, COLLECTION_C_NAME, + wp_properties_new ("wireplumber.collection", "true", NULL)); + wp_object_activate (WP_OBJECT (collection_c_metadata), + WP_OBJECT_FEATURES_ALL, NULL, + (GAsyncReadyCallback)on_metadata_activated, self); + g_main_loop_run (self->base.loop); + + /* Make sure collection C is still not added */ + g_assert_false (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_C_NAME)); + + /* Create collection C */ + wp_collection_manager_create_collection (self->collection_manager, + COLLECTION_C_NAME, NULL, (GAsyncReadyCallback)on_collection_created, self); + g_main_loop_run (self->base.loop); + + /* Make sure the manager has 3 collections */ + g_assert_cmpuint (wp_collection_manager_get_collection_count ( + self->collection_manager), ==, 3); + + /* Iterate collections */ + { + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + gboolean has_collection_a = FALSE; + gboolean has_collection_b = FALSE; + gboolean has_collection_c = FALSE; + guint count = 0; + + iter = wp_collection_manager_new_collection_iterator ( + self->collection_manager); + g_assert_nonnull (iter); + + while (wp_iterator_next (iter, &val)) { + WpCollection *c = g_value_get_object (&val); + g_assert_nonnull (c); + if (g_str_equal (wp_collection_get_name (c), COLLECTION_A_NAME)) + has_collection_a = TRUE; + if (g_str_equal (wp_collection_get_name (c), COLLECTION_B_NAME)) + has_collection_b = TRUE; + if (g_str_equal (wp_collection_get_name (c), COLLECTION_C_NAME)) + has_collection_c = TRUE; + count++; + g_value_unset (&val); + } + + g_assert_true (has_collection_a); + g_assert_true (has_collection_b); + g_assert_true (has_collection_c); + g_assert_cmpuint (count, ==, 3); + } + + /* Make sure collection C was added */ + g_assert_true (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_C_NAME)); + + /* Destroy collection C */ + g_assert_true (wp_collection_manager_destroy_collection ( + self->collection_manager, COLLECTION_C_NAME)); + + /* Make sure the manager has 2 collections */ + g_assert_cmpuint (wp_collection_manager_get_collection_count ( + self->collection_manager), ==, 2); + + /* Make sure collection C was removed */ + g_assert_false (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_C_NAME)); + + /* Remove collections A and B */ + g_assert_true (wp_collection_manager_destroy_collection ( + self->collection_manager, COLLECTION_A_NAME)); + g_assert_true (wp_collection_manager_destroy_collection ( + self->collection_manager, COLLECTION_B_NAME)); + + /* Make sure the manager does not have any collections */ + g_assert_cmpuint (wp_collection_manager_get_collection_count ( + self->collection_manager), ==, 0); + + /* Make sure collections A and B were removed */ + g_assert_false (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_A_NAME)); + g_assert_false (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_B_NAME)); +} + +static void +test_collection_manager_global (TestCollectionManagerFixture *self, + gconstpointer d) +{ + g_autoptr (WpGlobalProxy) global_a = NULL; + g_autoptr (WpGlobalProxy) global_b = NULL; + + /* Activate global A */ + global_a = WP_GLOBAL_PROXY (wp_impl_metadata_new_full (self->base.core, + "global-a", NULL)); + wp_object_activate (WP_OBJECT (global_a), WP_OBJECT_FEATURES_ALL, NULL, + (GAsyncReadyCallback)on_metadata_activated, self); + g_main_loop_run (self->base.loop); + + /* Activate global B */ + global_b = WP_GLOBAL_PROXY (wp_impl_metadata_new_full (self->base.core, + "global-a", NULL)); + wp_object_activate (WP_OBJECT (global_b), WP_OBJECT_FEATURES_ALL, NULL, + (GAsyncReadyCallback)on_metadata_activated, self); + g_main_loop_run (self->base.loop); + + /* Make sure collection A and B don't have any globals */ + g_assert_cmpuint (wp_collection_get_global_count (self->collection_a), ==, 0); + g_assert_cmpuint (wp_collection_get_global_count (self->collection_b), ==, 0); + + /* Make sure global A and B don't belong to any collection */ + g_assert_false (wp_collection_manager_is_global_collected ( + self->collection_manager, global_a, NULL)); + g_assert_false (wp_collection_manager_is_global_collected ( + self->collection_manager, global_b, NULL)); + + /* Collect global A into collection A and global B into collection B */ + g_assert_true (wp_collection_manager_collect_global (self->collection_manager, + global_a, COLLECTION_A_NAME)); + g_assert_true (wp_collection_manager_collect_global (self->collection_manager, + global_b, COLLECTION_B_NAME)); + + /* Make sure collection A and B have only one global */ + g_assert_cmpuint (wp_collection_get_global_count (self->collection_a), ==, 1); + g_assert_cmpuint (wp_collection_get_global_count (self->collection_b), ==, 1); + + /* Iterate collection A */ + { + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + guint count = 0; + + iter = wp_collection_manager_new_global_iterator (self->collection_manager, + COLLECTION_A_NAME); + g_assert_nonnull (iter); + + while (wp_iterator_next (iter, &val)) { + WpGlobalProxy *g = g_value_get_object (&val); + g_assert_nonnull (g); + g_assert_true (g == global_a); + count++; + g_value_unset (&val); + } + + g_assert_cmpuint (count, ==, 1); + } + + /* Iterate collection B */ + { + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + guint count = 0; + + iter = wp_collection_manager_new_global_iterator (self->collection_manager, + COLLECTION_B_NAME); + g_assert_nonnull (iter); + + while (wp_iterator_next (iter, &val)) { + WpGlobalProxy *g = g_value_get_object (&val); + g_assert_nonnull (g); + g_assert_true (g == global_b); + count++; + g_value_unset (&val); + } + + g_assert_cmpuint (count, ==, 1); + } + + /* Make sure global A and global B are in their respective collection */ + g_assert_true (wp_collection_manager_is_global_collected ( + self->collection_manager, global_a, COLLECTION_A_NAME)); + g_assert_true (wp_collection_manager_is_global_collected ( + self->collection_manager, global_b, COLLECTION_B_NAME)); + { + g_autoptr (WpCollection) ca = NULL; + g_autoptr (WpCollection) cb = NULL; + ca = wp_collection_manager_get_global_collection (self->collection_manager, + global_a); + g_assert_true (ca == self->collection_a); + cb = wp_collection_manager_get_global_collection (self->collection_manager, + global_b); + g_assert_true (cb == self->collection_b); + } + + g_assert_true (WP_IS_COLLECTION (self->collection_a)); + + /* Make sure we cannot add global A into collection B and vice versa */ + g_assert_false (wp_collection_manager_collect_global ( + self->collection_manager, global_a, COLLECTION_B_NAME)); + g_assert_false (wp_collection_manager_collect_global ( + self->collection_manager, global_b, COLLECTION_A_NAME)); + + /* Drop global A from collection A */ + g_assert_true (wp_collection_manager_drop_global (self->collection_manager, + global_a)); + + /* Make sure collection A does not have any globals */ + g_assert_cmpuint (wp_collection_get_global_count (self->collection_a), ==, 0); + + /* Make sure global A does not belong to any collection */ + g_assert_false (wp_collection_manager_is_global_collected ( + self->collection_manager, global_a, NULL)); + + /* Make sure collection A was automatically removed */ + g_assert_false (wp_collection_manager_has_collection ( + self->collection_manager, COLLECTION_A_NAME)); + + /* Collect global A into collection B */ + g_assert_true (wp_collection_manager_collect_global (self->collection_manager, + global_a, COLLECTION_B_NAME)); + + /* Make sure global A is in collection B */ + g_assert_true (wp_collection_manager_is_global_collected ( + self->collection_manager, global_a, COLLECTION_B_NAME)); + + /* Make sure collection B has 2 globals */ + g_assert_cmpuint (wp_collection_get_global_count (self->collection_b), ==, 2); + + /* Iterate collection B */ + { + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + gboolean has_global_a = FALSE; + gboolean has_global_b = FALSE; + guint count = 0; + + iter = wp_collection_manager_new_global_iterator (self->collection_manager, + COLLECTION_B_NAME); + g_assert_nonnull (iter); + + while (wp_iterator_next (iter, &val)) { + WpGlobalProxy *g = g_value_get_object (&val); + g_assert_nonnull (g); + if (g == global_a) + has_global_a = TRUE; + if (g == global_b) + has_global_b = TRUE; + count++; + g_value_unset (&val); + } + + g_assert_true (has_global_a); + g_assert_true (has_global_b); + g_assert_cmpuint (count, ==, 2); + } + + /* Drop global B from collection B */ + g_assert_true (wp_collection_manager_drop_global (self->collection_manager, + global_b)); + + /* Make sure collection B has only one global */ + g_assert_cmpuint (wp_collection_get_global_count (self->collection_b), ==, 1); + + /* Make sure global B does not belong to any collection */ + g_assert_false (wp_collection_manager_is_global_collected ( + self->collection_manager, global_b, NULL)); + + /* Drop global A from collection B */ + g_assert_true (wp_collection_manager_drop_global (self->collection_manager, + global_a)); + + /* Make sure collection B does not have any globals */ + g_assert_cmpuint (wp_collection_get_global_count (self->collection_b), ==, 0); + + /* Make sure global A does not belong to any collection */ + g_assert_false (wp_collection_manager_is_global_collected ( + self->collection_manager, global_a, NULL)); + + /* Make sure collection B was automatically removed */ + g_assert_false (wp_collection_manager_has_collection ( + self->collection_manager, COLLECTION_B_NAME)); +} + +int +main (int argc, char *argv[]) +{ + g_test_init (&argc, &argv, NULL); + wp_init (WP_INIT_ALL); + + g_test_add ("/wp/collection-manager/collection", + TestCollectionManagerFixture, NULL, collection_manager_setup, + test_collection_manager_collection, collection_manager_teardown); + g_test_add ("/wp/collection-manager/global", + TestCollectionManagerFixture, NULL, collection_manager_setup, + test_collection_manager_global, collection_manager_teardown); + + return g_test_run (); +} diff --git a/tests/wp/meson.build b/tests/wp/meson.build index cc0b9ac8..686d871c 100644 --- a/tests/wp/meson.build +++ b/tests/wp/meson.build @@ -3,6 +3,13 @@ common_env = common_test_env common_env.set('G_TEST_SRCDIR', meson.current_source_dir()) common_env.set('G_TEST_BUILDDIR', meson.current_build_dir()) +test( + 'test-collection-manager', + executable('test-collection-manager', 'collection-manager.c', + dependencies: common_deps), + env: common_env, +) + test( 'test-component-loader', executable('test-component-loader', 'component-loader.c', From 1913a4f2396f7b6b9160192078a14f5dd33af3a3 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Sun, 7 Dec 2025 10:59:41 -0500 Subject: [PATCH 03/17] modules: Add new collection-manager module This module is in charge of creating all the collection metadatas. --- lib/wp/private/internal-comp-loader.c | 15 ++ modules/meson.build | 10 ++ modules/module-collection-manager.c | 217 ++++++++++++++++++++++++++ src/config/wireplumber.conf | 22 +++ 4 files changed, 264 insertions(+) create mode 100644 modules/module-collection-manager.c diff --git a/lib/wp/private/internal-comp-loader.c b/lib/wp/private/internal-comp-loader.c index b6435335..0a997da6 100644 --- a/lib/wp/private/internal-comp-loader.c +++ b/lib/wp/private/internal-comp-loader.c @@ -780,6 +780,20 @@ load_settings_instance (GTask * task, WpCore * core, WpSpaJson * args) g_task_return_pointer (task, settings, g_object_unref); } +static void +load_collection_manager_instance (GTask * task, WpCore * core, WpSpaJson * args) +{ + g_autofree gchar *metadata_name = NULL; + if (args) + wp_spa_json_object_get (args, "metadata.name", "s", &metadata_name, NULL); + + wp_info_object (core, "loading collection manager instance '%s'...", + metadata_name ? metadata_name : "(default: sm-collection-manager)"); + + WpCollectionManager *cm = wp_collection_manager_new (core, metadata_name); + g_task_return_pointer (task, cm, g_object_unref); +} + static const struct { const gchar * name; void (*load) (GTask *, WpCore *, WpSpaJson *); @@ -787,6 +801,7 @@ static const struct { { "ensure-no-media-session", ensure_no_media_session }, { "export-core", load_export_core }, { "settings-instance", load_settings_instance }, + { "collection-manager-instance", load_collection_manager_instance }, }; /*** WpInternalCompLoader ***/ diff --git a/modules/meson.build b/modules/meson.build index 7308aa4e..3203f641 100644 --- a/modules/meson.build +++ b/modules/meson.build @@ -191,3 +191,13 @@ shared_library( install_dir : wireplumber_module_dir, dependencies : [wp_dep], ) + +shared_library( + 'wireplumber-module-collection-manager', + [ + 'module-collection-manager.c', + ], + install : true, + install_dir : wireplumber_module_dir, + dependencies : [wp_dep, pipewire_dep], +) diff --git a/modules/module-collection-manager.c b/modules/module-collection-manager.c new file mode 100644 index 00000000..1326b16f --- /dev/null +++ b/modules/module-collection-manager.c @@ -0,0 +1,217 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#include +#include + +WP_DEFINE_LOCAL_LOG_TOPIC ("m-collection-manager") + +/* + * This module creates the "sm-collection-manager" metadata and also each + * individual collection metadata. + */ + +struct _WpCollectionManagerPlugin +{ + WpPlugin parent; + + /* Props */ + gchar *metadata_name; + + WpImplMetadata *impl_metadata; + GHashTable *collection_metadatas; +}; + +enum { + PROP_0, + PROP_METADATA_NAME, +}; + +G_DECLARE_FINAL_TYPE (WpCollectionManagerPlugin, wp_collection_manager_plugin, + WP, COLLECTION_MANAGER_PLUGIN, WpPlugin) +G_DEFINE_TYPE (WpCollectionManagerPlugin, wp_collection_manager_plugin, + WP_TYPE_PLUGIN) + +static void +wp_collection_manager_plugin_init (WpCollectionManagerPlugin * self) +{ +} + +static void +on_collection_metadata_activated (GObject * o, GAsyncResult * res, gpointer d) +{ + WpCollectionManagerPlugin *self = WP_COLLECTION_MANAGER_PLUGIN (d); + g_autoptr (WpImplMetadata) m = WP_IMPL_METADATA (o); + g_autoptr (WpProperties) props = NULL; + const gchar *name = NULL; + g_autoptr (GError) error = NULL; + + /* Get the metadata name */ + props = wp_global_proxy_get_global_properties (WP_GLOBAL_PROXY (m)); + g_return_if_fail (props); + name = wp_properties_get (props, "metadata.name"); + g_return_if_fail (name); + + /* make sure activation was successful */ + if (!wp_object_activate_finish (WP_OBJECT (m), res, &error)) { + wp_warning_object (self, "Failed to activate collection metadata: %s", + name); + return; + } + + /* Add the metadata */ + g_hash_table_insert (self->collection_metadatas, g_strdup (name), + g_object_ref (m)); + wp_info_object (self, "Added collection metadata: %s", name); +} + +static void +on_metadata_changed (WpMetadata *m, guint32 subject, const gchar *key, + const gchar *type, const gchar *value, gpointer d) +{ + WpCollectionManagerPlugin *self = WP_COLLECTION_MANAGER_PLUGIN (d); + g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self)); + + if (key) { + WpImplMetadata *collection_m = g_hash_table_lookup ( + self->collection_metadatas, key); + if (collection_m && !value) { + /* Remove collection metadata */ + g_hash_table_remove (self->collection_metadatas, key); + wp_info_object (self, "Removed collection metadata: %s", key); + } else if (!collection_m && value) { + /* Create new collection metadata */ + wp_info_object (self, "Creating collection metadata: %s", key); + WpImplMetadata *new_collection_m = wp_impl_metadata_new_full (core, key, + wp_properties_new ("wireplumber.collection", "true", NULL)); + wp_object_activate (WP_OBJECT (new_collection_m), WP_OBJECT_FEATURES_ALL, + NULL, on_collection_metadata_activated, self); + } + } else { + /* Remove all collection metadatas */ + g_hash_table_remove_all (self->collection_metadatas); + wp_info_object (self, "Removed all collection metadatas"); + } +} + +static void +on_metadata_activated (WpMetadata * m, GAsyncResult * res, + gpointer user_data) +{ + WpTransition *trans = WP_TRANSITION (user_data); + WpCollectionManagerPlugin *self = wp_transition_get_source_object (trans); + g_autoptr (GError) error = NULL; + + if (!wp_object_activate_finish (WP_OBJECT (m), res, &error)) { + wp_transition_return_error (trans, g_error_new (WP_DOMAIN_LIBRARY, + WP_LIBRARY_ERROR_OPERATION_FAILED, + "Failed to activate metadata object %s", self->metadata_name)); + return; + } + + /* monitor changes in metadata */ + g_signal_connect_object (m, "changed", G_CALLBACK (on_metadata_changed), self, + 0); + + wp_object_update_features (WP_OBJECT (self), WP_PLUGIN_FEATURE_ENABLED, 0); +} + +static void +wp_collection_manager_plugin_enable (WpPlugin * plugin, + WpTransition * transition) +{ + WpCollectionManagerPlugin * self = WP_COLLECTION_MANAGER_PLUGIN (plugin); + g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (plugin)); + + /* Create the collection metadatas table */ + self->collection_metadatas = g_hash_table_new_full (g_str_hash, g_str_equal, + g_free, g_object_unref); + + /* Create the collection manager metadata */ + self->impl_metadata = wp_impl_metadata_new_full (core, self->metadata_name, + wp_properties_new ("wireplumber.collection-manager", "true", NULL)); + wp_object_activate (WP_OBJECT (self->impl_metadata), WP_OBJECT_FEATURES_ALL, + NULL, + (GAsyncReadyCallback)on_metadata_activated, transition); +} + +static void +wp_collection_manager_plugin_disable (WpPlugin * plugin) +{ + WpCollectionManagerPlugin * self = WP_COLLECTION_MANAGER_PLUGIN (plugin); + + g_clear_object (&self->impl_metadata); + g_clear_pointer (&self->collection_metadatas, g_hash_table_unref); + + g_clear_pointer (&self->metadata_name, g_free); +} + +static void +wp_collection_manager_plugin_set_property (GObject * object, guint property_id, + const GValue * value, GParamSpec * pspec) +{ + WpCollectionManagerPlugin *self = WP_COLLECTION_MANAGER_PLUGIN (object); + + switch (property_id) { + case PROP_METADATA_NAME: + g_clear_pointer (&self->metadata_name, g_free); + self->metadata_name = g_value_dup_string (value); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +static void +wp_collection_manager_plugin_get_property (GObject * object, guint property_id, + GValue * value, GParamSpec * pspec) +{ + WpCollectionManagerPlugin *self = WP_COLLECTION_MANAGER_PLUGIN (object); + + switch (property_id) { + case PROP_METADATA_NAME: + g_value_set_string (value, self->metadata_name); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +static void +wp_collection_manager_plugin_class_init (WpCollectionManagerPluginClass * klass) +{ + WpPluginClass *plugin_class = (WpPluginClass *) klass; + GObjectClass *object_class = (GObjectClass *) klass; + + plugin_class->enable = wp_collection_manager_plugin_enable; + plugin_class->disable = wp_collection_manager_plugin_disable; + + object_class->set_property = wp_collection_manager_plugin_set_property; + object_class->get_property = wp_collection_manager_plugin_get_property; + + g_object_class_install_property (object_class, PROP_METADATA_NAME, + g_param_spec_string ("metadata-name", "metadata-name", + "The metadata object to look after", NULL, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); +} + +WP_PLUGIN_EXPORT GObject * +wireplumber__module_init (WpCore * core, WpSpaJson * args, GError ** error) +{ + g_autofree gchar *metadata_name = NULL; + if (args) + wp_spa_json_object_get (args, "metadata.name", "s", &metadata_name, NULL); + + return G_OBJECT (g_object_new (wp_collection_manager_plugin_get_type (), + "name", "collection-manager", + "core", core, + "metadata-name", metadata_name ? metadata_name : "sm-collection-manager", + NULL)); +} diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf index 63a4046b..85bfe2de 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -68,6 +68,7 @@ wireplumber.profiles = { inherits = [ base ] metadata.sm-settings = required + metadata.sm-collection-manager = required metadata.sm-objects = required policy.standard = required @@ -101,6 +102,7 @@ wireplumber.profiles = { policy = { inherits = [ base ] metadata.sm-settings = required + metadata.sm-collection-manager = required metadata.sm-objects = required policy.standard = required } @@ -128,6 +130,7 @@ wireplumber.profiles = { base = { check.no-media-session = required support.settings = required + support.collection-manager = required support.log-settings = required support.session-services = required } @@ -233,6 +236,13 @@ wireplumber.components = [ provides = metadata.sm-settings } + ## Provides the "sm-collection-manager" metadata object + { + name = libwireplumber-module-collection-manager, type = module + arguments = { metadata.name = sm-collection-manager } + provides = metadata.sm-collection-manager + } + ## Activates a global WpSettings instance, providing settings from ## the sm-settings metadata object. Note that this blocks and waits for the ## sm-settings metadata object to become available, so one instance must @@ -244,6 +254,18 @@ wireplumber.components = [ after = [ metadata.sm-settings ] } + ## Activates a global WpCollectionManager instance, allowing creating + ## collections for global objects. Note that this blocks and waits for the + ## sm-collection-manager metadata object to become available, so one instance + ## must provide that, while others should only load this to access the + ## collection manager. + { + name = collection-manager-instance, type = built-in + arguments = { metadata.name = sm-collection-manager } + provides = support.collection-manager + after = [ metadata.sm-collection-manager ] + } + ## Log level settings { name = libwireplumber-module-log-settings, type = module From 58ad3a1976672614a7e679de26e5c030ebede723 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Mon, 8 Dec 2025 16:05:20 -0500 Subject: [PATCH 04/17] wpctl: Add new 'collections' command This new command shows information about collections. --- src/tools/wpctl.c | 172 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/src/tools/wpctl.c b/src/tools/wpctl.c index 4a519f7e..87c55e35 100644 --- a/src/tools/wpctl.c +++ b/src/tools/wpctl.c @@ -89,6 +89,10 @@ static struct { gboolean reset; } settings; + struct { + const gchar *collection_name; + } collections; + struct { guint64 id; const char *level; @@ -1592,6 +1596,139 @@ out: g_main_loop_quit (self->loop); } +/* collections */ + +static gboolean +collections_parse_positional (gint argc, gchar ** argv, GError **error) +{ + cmdline.collections.collection_name = NULL; + + if (argc >= 3) + cmdline.collections.collection_name = argv[2]; + + return TRUE; +} + +static gboolean +collections_prepare (WpCtl * self, GError ** error) +{ + wp_object_manager_add_interest (self->om, WP_TYPE_GLOBAL_PROXY, NULL); + wp_object_manager_request_object_features (self->om, WP_TYPE_GLOBAL_PROXY, + WP_OBJECT_FEATURES_ALL); + return TRUE; +} + +static void +print_global (WpCollection *collection, WpGlobalProxy *global) +{ + g_autoptr (WpProperties) props = NULL; + const gchar *global_name = NULL; + gchar global_type = '-'; + guint32 bound_id; + + props = wp_global_proxy_get_global_properties (global); + bound_id = wp_proxy_get_bound_id (WP_PROXY (global)); + + if (WP_IS_NODE (global)) { + global_name = wp_properties_get (props, "node.name"); + global_type = 'n'; + } else if (WP_IS_PORT (global)) { + global_name = wp_properties_get (props, "port.name"); + global_type = 'p'; + } else if (WP_IS_DEVICE (global)) { + global_name = wp_properties_get (props, "device.name"); + global_type = 'd'; + } else if (WP_IS_CLIENT (global)) { + global_name = wp_properties_get (props, "client.name"); + global_type = 'c'; + } else if (WP_IS_METADATA (global)) { + global_name = wp_properties_get (props, "metadata.name"); + global_type = 'm'; + } else if (WP_IS_FACTORY (global)) { + global_name = wp_properties_get (props, "factory.name"); + global_type = 'f'; + } + + g_print (" [%c] %4u. %s\n", global_type, bound_id, + global_name ? global_name : "UNKNOWN"); +} + +static void +print_collection (WpCollectionManager *cm, WpCollection *collection) +{ + const gchar *collection_name = wp_collection_get_name (collection); + g_autoptr (WpMetadata) meta = NULL; + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + guint32 bound_id; + + meta = wp_collection_get_metadata (collection); + if (!meta) + return; + bound_id = wp_proxy_get_bound_id (WP_PROXY (meta)); + + g_print ("%4u. %s\n", + bound_id, + collection_name); + + iter = wp_collection_manager_new_global_iterator (cm, collection_name); + while (wp_iterator_next (iter, &val)) { + WpGlobalProxy *global = g_value_get_object (&val); + print_global (collection, global); + g_value_unset (&val); + } + + printf ("\n"); +} + +static void +print_collections (WpCollectionManager *cm) +{ + g_autoptr (WpIterator) it = NULL; + g_auto (GValue) item = G_VALUE_INIT; + + printf ("Collections:\n\n"); + + it = wp_collection_manager_new_collection_iterator (cm); + while (wp_iterator_next (it, &item)) { + WpCollection *collection = g_value_get_object (&item); + print_collection (cm, collection); + g_value_unset (&item); + } +} + +static void +collections_run (WpCtl * self) +{ + g_autoptr (WpCollectionManager) cm = NULL; + const gchar *collection_name = cmdline.collections.collection_name; + + cm = wp_collection_manager_find (self->core, NULL); + if (!cm) { + printf ("Could not find registered collection manager\n"); + goto error; + } + + /* Print all collections if name is not provided */ + if (collection_name) { + WpCollection *collection; + collection = wp_collection_manager_get_collection (cm, collection_name); + if (collection) + print_collection (cm, collection); + else + printf ("Collection '%s' does not exist\n", collection_name); + } else { + print_collections (cm); + } + + wp_core_sync (self->core, NULL, (GAsyncReadyCallback) async_quit, self); + return; + +error: + self->exit_code = 3; + g_main_loop_quit (self->loop); +} + /* set-log-level */ static gboolean @@ -1840,6 +1977,16 @@ static const struct subcommand { .prepare = settings_prepare, .run = settings_run, }, + { + .name = "collections", + .positional_args = "[COLLECTION]", + .summary = "Shows information about collections", + .description = NULL, + .entries = { { NULL } }, + .parse_positional = collections_parse_positional, + .prepare = collections_prepare, + .run = collections_run, + }, { .name = "set-log-level", .positional_args = "[ID] LEVEL", @@ -1867,6 +2014,22 @@ on_settings_activated (WpSettings *s, GAsyncResult *res, WpCtl *ctl) wp_core_register_object (ctl->core, g_object_ref (s)); } +static void +on_collection_manager_activated (WpCollectionManager *cm, GAsyncResult *res, + WpCtl *ctl) +{ + GError *error = NULL; + + if (!wp_object_activate_finish (WP_OBJECT (cm), res, &error)) { + fprintf (stderr, "%s\n", error->message); + ctl->exit_code = 1; + g_main_loop_quit (ctl->loop); + return; + } + + wp_core_register_object (ctl->core, g_object_ref (cm)); +} + static void on_plugin_loaded (WpCore * core, GAsyncResult * res, WpCtl *ctl) { @@ -1894,6 +2057,7 @@ main (gint argc, gchar **argv) g_autoptr (GError) error = NULL; g_autofree gchar *summary = NULL; g_autoptr (WpSettings) settings = NULL; + g_autoptr (WpCollectionManager) collection_manager = NULL; setlocale (LC_ALL, ""); setlocale (LC_NUMERIC, "C"); @@ -1977,6 +2141,14 @@ main (gint argc, gchar **argv) (GAsyncReadyCallback)on_settings_activated, &ctl); + /* load and register the collection manager */ + collection_manager = wp_collection_manager_new (ctl.core, NULL); + wp_object_activate (WP_OBJECT (collection_manager), + WP_OBJECT_FEATURES_ALL, + NULL, + (GAsyncReadyCallback)on_collection_manager_activated, + &ctl); + /* load required API modules */ ctl.pending_plugins++; wp_core_load_component (ctl.core, "libwireplumber-module-default-nodes-api", From 155bbbfc38980a3d43c7994e210a66799bd885e2 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 10 Dec 2025 12:06:45 -0500 Subject: [PATCH 05/17] m-lua-scripting: Add WpCollection and WpCollectionManager Lua APIs This allows using the new collections API in Lua scripts. --- modules/module-lua-scripting/api/api.c | 298 +++++++++++++++++++++++ modules/module-lua-scripting/api/api.lua | 1 + 2 files changed, 299 insertions(+) diff --git a/modules/module-lua-scripting/api/api.c b/modules/module-lua-scripting/api/api.c index 8374807b..5ba678bc 100644 --- a/modules/module-lua-scripting/api/api.c +++ b/modules/module-lua-scripting/api/api.c @@ -2179,6 +2179,53 @@ static const luaL_Reg properties_funcs[] = { { NULL, NULL } }; +/* WpCollection */ + +static int +collection_get_name (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + lua_pushstring (L, wp_collection_get_name (c)); + return 1; +} + +static int +collection_get_metadata (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + WpMetadata *m = wp_collection_get_metadata (c); + if (m) + wplua_pushobject (L, m); + else + lua_pushnil (L); + return 1; +} + +static int +collection_contains_global (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + WpGlobalProxy *g = wplua_checkobject (L, 2, WP_TYPE_GLOBAL_PROXY); + lua_pushboolean (L, wp_collection_contains_global (c, g)); + return 1; +} + +static int +collection_get_global_count (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + lua_pushinteger (L, wp_collection_get_global_count (c)); + return 1; +} + +static const luaL_Reg collection_funcs[] = { + { "get_name", collection_get_name }, + { "get_metadata", collection_get_metadata }, + { "contains_global", collection_contains_global }, + { "get_global_count", collection_get_global_count }, + { NULL, NULL } +}; + /* WpSettings */ static int @@ -2734,6 +2781,252 @@ static const luaL_Reg permission_manager_funcs[] = { { NULL, NULL } }; +/* WpCollectionManager */ + +static void +on_collection_created (WpCollectionManager *cm, GAsyncResult * res, + GClosure * closure) +{ + g_autoptr (GError) error = NULL; + WpCollection *c; + GValue val[2] = { G_VALUE_INIT, G_VALUE_INIT }; + int n_vals = 1; + + c = wp_collection_manager_create_collection_finish (cm, res, &error); + if (!c) { + g_value_init (&val[1], G_TYPE_STRING); + g_value_set_string (&val[1], error->message); + n_vals = 2; + } + + g_value_init (&val[0], WP_TYPE_COLLECTION); + g_value_set_object (&val[0], c); + g_closure_invoke (closure, NULL, n_vals, val, NULL); + g_value_unset (&val[0]); + g_value_unset (&val[1]); + g_closure_invalidate (closure); + g_closure_unref (closure); +} + +static int +collection_manager_create_collection (lua_State *L) +{ + const char *name = luaL_checkstring (L, 1); + GClosure * closure = wplua_function_to_closure (L, 2); + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + GValue val[2] = { G_VALUE_INIT, G_VALUE_INIT }; + g_value_init (&val[0], WP_TYPE_METADATA); + g_value_set_object (&val[0], NULL); + g_value_init (&val[1], G_TYPE_STRING); + g_value_set_string (&val[1], "Could not find collection manager"); + g_closure_invoke (closure, NULL, 2, val, NULL); + g_value_unset (&val[0]); + g_value_unset (&val[1]); + g_closure_invalidate (closure); + g_closure_unref (closure); + return 0; + } + + wp_collection_manager_create_collection (cm, name, NULL, + (GAsyncReadyCallback) on_collection_created, closure); + return 0; +} + +static int +collection_manager_destroy_collection (lua_State *L) +{ + const char *name = luaL_checkstring (L, 1); + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushboolean (L, FALSE); + return 1; + } + + lua_pushboolean (L, wp_collection_manager_destroy_collection (cm, name)); + return 1; +} + +static int +collection_manager_has_collection (lua_State *L) +{ + const char *name = luaL_checkstring (L, 1); + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushboolean (L, FALSE); + return 1; + } + + lua_pushboolean (L, wp_collection_manager_has_collection (cm, name)); + return 1; +} + +static int +collection_manager_get_collection (lua_State *L) +{ + const char *name = luaL_checkstring (L, 1); + g_autoptr (WpCollectionManager) cm = NULL; + WpCollection *c = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushnil (L); + return 1; + } + + c = wp_collection_manager_get_collection (cm, name); + if (c) + wplua_pushobject (L, c); + else + lua_pushnil (L); + return 1; +} + +static int +collection_manager_get_collection_count (lua_State *L) +{ + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushinteger (L, 0); + return 1; + } + + lua_pushinteger (L, wp_collection_manager_get_collection_count (cm)); + return 1; +} + +static int +collection_manager_iterate_collections (lua_State *L) +{ + g_autoptr (WpCollectionManager) cm = NULL; + g_autoptr (WpIterator) it = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushnil (L); + return 1; + } + + it = wp_collection_manager_new_collection_iterator (cm); + return push_wpiterator (L, it); +} + +static int +collection_manager_collect_global (lua_State *L) +{ + WpGlobalProxy *global = wplua_checkobject (L, 1, WP_TYPE_GLOBAL_PROXY); + const char *name = luaL_checkstring (L, 2); + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushboolean (L, FALSE); + return 1; + } + + lua_pushboolean (L, wp_collection_manager_collect_global (cm, global, name)); + return 1; +} + +static int +collection_manager_drop_global (lua_State *L) +{ + WpGlobalProxy *global = wplua_checkobject (L, 1, WP_TYPE_GLOBAL_PROXY); + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushboolean (L, FALSE); + return 1; + } + + lua_pushboolean (L, wp_collection_manager_drop_global (cm, global)); + return 1; +} + +static int +collection_manager_get_global_collection (lua_State *L) +{ + WpGlobalProxy *global = wplua_checkobject (L, 1, WP_TYPE_GLOBAL_PROXY); + g_autoptr (WpCollectionManager) cm = NULL; + WpCollection *c = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushnil (L); + return 1; + } + + c = wp_collection_manager_get_global_collection (cm, global); + if (c) + wplua_pushobject (L, c); + else + lua_pushnil (L); + return 1; +} + +static int +collection_manager_is_global_collected (lua_State *L) +{ + WpGlobalProxy *global = wplua_checkobject (L, 1, WP_TYPE_GLOBAL_PROXY); + const gchar *collection_name = NULL; + if (!lua_isnone (L, 2) && !lua_isnil (L, 2)) + collection_name = luaL_checkstring (L, 2); + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushboolean (L, FALSE); + return 1; + } + + lua_pushboolean (L, wp_collection_manager_is_global_collected (cm, global, + collection_name)); + return 1; +} + +static int +collection_manager_iterate_globals (lua_State *L) +{ + const gchar *collection_name = NULL; + if (!lua_isnone (L, 1) && !lua_isnil (L, 1)) + collection_name = luaL_checkstring (L, 1); + g_autoptr (WpCollectionManager) cm = NULL; + g_autoptr (WpIterator) it = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushnil (L); + return 1; + } + + it = wp_collection_manager_new_global_iterator (cm, collection_name); + return push_wpiterator (L, it); +} + +static const luaL_Reg collection_manager_funcs[] = { + { "create_collection", collection_manager_create_collection }, + { "destroy_collection", collection_manager_destroy_collection }, + { "has_collection", collection_manager_has_collection }, + { "get_collection", collection_manager_get_collection }, + { "get_collection_count", collection_manager_get_collection_count }, + { "iterate_collections", collection_manager_iterate_collections }, + { "collect_global", collection_manager_collect_global }, + { "drop_global", collection_manager_drop_global }, + { "get_global_collection", collection_manager_get_global_collection }, + { "is_global_collected", collection_manager_is_global_collected }, + { "iterate_globals", collection_manager_iterate_globals }, + { NULL, NULL } +}; + /* WpEventHook */ static int @@ -3200,6 +3493,9 @@ wp_lua_scripting_api_init (lua_State *L) luaL_newlib (L, event_dispatcher_funcs); lua_setglobal (L, "WpEventDispatcher"); + luaL_newlib (L, collection_manager_funcs); + lua_setglobal (L, "WpCollectionManager"); + wp_lua_scripting_pod_init (L); wp_lua_scripting_json_init (L); @@ -3263,6 +3559,8 @@ wp_lua_scripting_api_init (lua_State *L) properties_new, properties_funcs); wplua_register_type_methods (L, WP_TYPE_PERMISSION_MANAGER, permission_manager_new, permission_manager_funcs); + wplua_register_type_methods (L, WP_TYPE_COLLECTION, + NULL, collection_funcs); if (!wplua_load_uri (L, URI_API, &error) || !wplua_pcall (L, 0, 0, &error)) { diff --git a/modules/module-lua-scripting/api/api.lua b/modules/module-lua-scripting/api/api.lua index fe9dcdcf..3f968425 100644 --- a/modules/module-lua-scripting/api/api.lua +++ b/modules/module-lua-scripting/api/api.lua @@ -237,6 +237,7 @@ SANDBOX_EXPORT = { LocalModule = WpImplModule_new, ImplMetadata = WpImplMetadata_new, Settings = WpSettings, + CollectionManager = WpCollectionManager, Conf = WpConf, JsonUtils = JsonUtils, ProcUtils = ProcUtils, From 5a2b15c1a0d62a52fb9b15c3d937f3830f1b51b9 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 10 Dec 2025 14:55:24 -0500 Subject: [PATCH 06/17] m-standard-event-source: Emit 'select-collection' event for all added objects This new event has higher priority than the 'added' event, and it is meant to be used by hooks that collect objects into collections. --- modules/module-standard-event-source.c | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/module-standard-event-source.c b/modules/module-standard-event-source.c index e23696bc..f89fc00b 100644 --- a/modules/module-standard-event-source.c +++ b/modules/module-standard-event-source.c @@ -353,6 +353,7 @@ on_node_state_changed (WpNode *obj, WpNodeState old_state, static void on_object_added (WpObjectManager *om, WpObject *obj, WpStandardEventSource *self) { + wp_standard_event_source_push_event (self, "select-collection", obj, NULL); wp_standard_event_source_push_event (self, "added", obj, NULL); if (WP_IS_PIPEWIRE_OBJECT (obj)) { From a71327076900a1ede93d510d1ed7504da3df9f90 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 31 Dec 2025 10:39:27 -0500 Subject: [PATCH 07/17] m-standard-event-source: Emit '*-collected' and '*-dropped' events These events are emitted when a global was collected into a collection or dropped from a collection respectively. --- modules/module-standard-event-source.c | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/modules/module-standard-event-source.c b/modules/module-standard-event-source.c index f89fc00b..3260949a 100644 --- a/modules/module-standard-event-source.c +++ b/modules/module-standard-event-source.c @@ -69,6 +69,7 @@ struct _WpStandardEventSource WpEventHook *rescan_done_hook; gboolean rescan_scheduled[N_RESCAN_CONTEXTS]; gint n_oms_installed; + WpCollectionManager *cm; }; static guint signals[N_SIGNALS] = {0}; @@ -310,6 +311,26 @@ wp_standard_event_source_schedule_rescan (WpStandardEventSource *self, } } +static void +on_global_collected (WpObjectManager *om, WpGlobalProxy *global, + const gchar *collection_name, WpStandardEventSource *self) +{ + g_autoptr (WpProperties) properties = wp_properties_new_empty (); + wp_properties_set (properties, "event.subject.collection", collection_name); + + wp_standard_event_source_push_event (self, "collected", global, properties); +} + +static void +on_global_dropped (WpObjectManager *om, WpGlobalProxy *global, + const gchar *collection_name, WpStandardEventSource *self) +{ + g_autoptr (WpProperties) properties = wp_properties_new_empty (); + wp_properties_set (properties, "event.subject.collection", collection_name); + + wp_standard_event_source_push_event (self, "dropped", global, properties); +} + static void on_metadata_changed (WpMetadata *obj, guint32 subject, const gchar *key, const gchar *spa_type, const gchar *value, @@ -409,6 +430,15 @@ wp_standard_event_source_enable (WpPlugin * plugin, WpTransition * transition) wp_event_dispatcher_get_instance (core); g_return_if_fail (dispatcher); + /* Get the collection manager if any */ + self->cm = wp_collection_manager_find (core, NULL); + if (self->cm) { + g_signal_connect_object (self->cm, "global-collected", + G_CALLBACK (on_global_collected), self, 0); + g_signal_connect_object (self->cm, "global-dropped", + G_CALLBACK (on_global_dropped), self, 0); + } + /* install object managers */ self->n_oms_installed = 0; for (gint i = 0; i < N_OBJECT_TYPES; i++) { @@ -451,6 +481,8 @@ wp_standard_event_source_disable (WpPlugin * plugin) if (dispatcher) wp_event_dispatcher_unregister_hook (dispatcher, self->rescan_done_hook); g_clear_object (&self->rescan_done_hook); + + g_clear_object (&self->cm); } static void From e78a28f5b6b4c1932a71c7e918536cdec42668e1 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 17 Feb 2026 11:56:28 -0500 Subject: [PATCH 08/17] permission-manager: Update permissions when a matched object has been collected or dropped This is needed because the 'collection-name' gobject property changes if the object has been collected or dropped. --- lib/wp/permission-manager.c | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lib/wp/permission-manager.c b/lib/wp/permission-manager.c index 1f059e5b..a369d2b1 100644 --- a/lib/wp/permission-manager.c +++ b/lib/wp/permission-manager.c @@ -11,6 +11,7 @@ #include "private/permission-manager.h" #include "permission-manager.h" +#include "collection-manager.h" #include "proxy-interfaces.h" #include "object-manager.h" #include "json-utils.h" @@ -80,6 +81,7 @@ struct _WpPermissionManager GHashTable *matches; WpObjectManager *om; + WpCollectionManager *cm; }; G_DEFINE_TYPE (WpPermissionManager, wp_permission_manager, WP_TYPE_OBJECT) @@ -366,6 +368,16 @@ update_permissions (WpPermissionManager *self) } } +static void +on_global_collected_or_dropped (WpCollectionManager *cm, WpGlobalProxy *global, + const gchar *collection_name, gpointer *d) +{ + WpPermissionManager * self = WP_PERMISSION_MANAGER (d); + + if (has_object_match (self, global)) + update_permissions (self); +} + static void on_object_added_or_removed (WpObjectManager *om, WpGlobalProxy *object, gpointer d) @@ -395,6 +407,15 @@ wp_permission_manager_activate_execute_step (WpObject * object, switch (step) { case STEP_LOAD: { + /* Get the collection manager if any */ + self->cm = wp_collection_manager_find (core, NULL); + if (self->cm) { + g_signal_connect_object (self->cm, "global-collected", + G_CALLBACK (on_global_collected_or_dropped), self, 0); + g_signal_connect_object (self->cm, "global-dropped", + G_CALLBACK (on_global_collected_or_dropped), self, 0); + } + /* Install object manager */ g_clear_object (&self->om); self->om = wp_object_manager_new (); @@ -424,6 +445,7 @@ wp_permission_manager_deactivate (WpObject * object, WpObjectFeatures features) WpPermissionManager *self = WP_PERMISSION_MANAGER (object); g_clear_object (&self->om); + g_clear_object (&self->cm); wp_object_update_features (WP_OBJECT (self), 0, WP_OBJECT_FEATURES_ALL); } @@ -437,6 +459,7 @@ wp_permission_manager_finalize (GObject * object) g_clear_pointer (&self->matches, g_hash_table_unref); g_clear_object (&self->om); + g_clear_object (&self->cm); G_OBJECT_CLASS (wp_permission_manager_parent_class)->finalize (object); } From bf1d2c82b71f32327f1a1c36606c43cc43a85d13 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Mon, 9 Feb 2026 10:34:10 -0500 Subject: [PATCH 09/17] global-proxy: Add collection-name property Helper function to get the collection name of a global proxy easily. This property can also be used in object interest constrains to select objects of a particular collection. --- lib/wp/global-proxy.c | 38 ++++++++++++++++++++++++++++++++++++++ lib/wp/global-proxy.h | 3 +++ 2 files changed, 41 insertions(+) diff --git a/lib/wp/global-proxy.c b/lib/wp/global-proxy.c index 046795aa..d7f70208 100644 --- a/lib/wp/global-proxy.c +++ b/lib/wp/global-proxy.c @@ -7,6 +7,7 @@ */ #include "global-proxy.h" +#include "collection-manager.h" #include "private/registry.h" #include "core.h" #include "error.h" @@ -51,6 +52,7 @@ enum { PROP_FACTORY_NAME, PROP_GLOBAL_PROPERTIES, PROP_PERMISSIONS, + PROP_COLLECTION_NAME, }; G_DEFINE_TYPE_WITH_PRIVATE (WpGlobalProxy, wp_global_proxy, WP_TYPE_PROXY) @@ -125,6 +127,9 @@ wp_global_proxy_get_property (GObject * object, guint property_id, case PROP_GLOBAL_PROPERTIES: g_value_take_boxed (value, wp_global_proxy_get_global_properties (self)); break; + case PROP_COLLECTION_NAME: + g_value_set_string (value, wp_global_proxy_get_collection_name (self)); + break; default: G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); break; @@ -308,6 +313,11 @@ wp_global_proxy_class_init (WpGlobalProxyClass * klass) g_param_spec_uint ("permissions", "permissions", "The pipewire global permissions", 0, G_MAXUINT, 0, G_PARAM_READABLE | G_PARAM_STATIC_STRINGS)); + + g_object_class_install_property (object_class, PROP_COLLECTION_NAME, + g_param_spec_string ("collection-name", "collection-name", + "The collection name this global proxy belongs to", NULL, + G_PARAM_READABLE | G_PARAM_STATIC_STRINGS)); } /*! @@ -376,6 +386,34 @@ wp_global_proxy_get_global_properties (WpGlobalProxy * self) return NULL; } +/*! + * \brief Gets the collection name of the pipewire global if it was collected + * into a collection. + * \ingroup wpglobalproxy + * \param self the pipewire global + * \returns (nullable): the collection name this pipewire global belongs to, or + * NULL if the pipewire global does not belong to any collection + */ +const gchar * +wp_global_proxy_get_collection_name (WpGlobalProxy * self) +{ + g_autoptr (WpCore) core = NULL; + const gchar *collection_name = NULL; + + core = wp_object_get_core (WP_OBJECT (self)); + if (core) { + g_autoptr (WpCollectionManager) cm = NULL; + cm = wp_collection_manager_find (core, NULL); + if (cm) { + g_autoptr (WpCollection) c = NULL; + c = wp_collection_manager_get_global_collection (cm, self); + if (c) + collection_name = wp_collection_get_name (c); + } + } + return collection_name; +} + /*! * \brief Binds to the global and creates the underlying `pw_proxy`. * diff --git a/lib/wp/global-proxy.h b/lib/wp/global-proxy.h index 3bb82084..e1d45504 100644 --- a/lib/wp/global-proxy.h +++ b/lib/wp/global-proxy.h @@ -41,6 +41,9 @@ WP_API WpProperties * wp_global_proxy_get_global_properties ( WpGlobalProxy * self); +WP_API +const gchar * wp_global_proxy_get_collection_name (WpGlobalProxy * self); + WP_API gboolean wp_global_proxy_bind (WpGlobalProxy * self); From 5f26e4d01ac579acb6850a026d166077d357ad3f Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Mon, 15 Dec 2025 16:32:36 -0500 Subject: [PATCH 10/17] m-standard-event-source: Add priority for new 'destroy-' local event types This new event type have the same priority as the 'create-' one. --- modules/module-standard-event-source.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/module-standard-event-source.c b/modules/module-standard-event-source.c index 3260949a..ea2bf3c3 100644 --- a/modules/module-standard-event-source.c +++ b/modules/module-standard-event-source.c @@ -158,7 +158,8 @@ static gint get_default_event_priority (const gchar *event_type) { if (g_str_has_prefix(event_type, "select-") || - g_str_has_prefix(event_type, "create-")) + g_str_has_prefix(event_type, "create-") || + g_str_has_prefix(event_type, "destroy-")) return 500; if (g_str_has_prefix(event_type, "autoswitch-")) return 400; @@ -212,8 +213,9 @@ static gboolean is_it_local_event (const gchar *event_type) { if (g_str_has_prefix(event_type, "select-") || - g_str_has_prefix(event_type, "create-") || - g_str_has_prefix(event_type, "autoswitch-")) + g_str_has_prefix(event_type, "create-") || + g_str_has_prefix(event_type, "destroy-") || + g_str_has_prefix(event_type, "autoswitch-")) return TRUE; return FALSE; From b96c97e294b2489026748ee531d616f621543188 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 17 Dec 2025 12:10:08 -0500 Subject: [PATCH 11/17] create-item.lua: Handle nodes that are collected into collections This change makes sure the session items are always updated even after nodes are collected or dropped from a collection. We also set a 'collection.name' property so we can easily filter nodes by collection. --- src/scripts/node/create-item.lua | 65 ++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/src/scripts/node/create-item.lua b/src/scripts/node/create-item.lua index 259ebc83..f77e0dc2 100644 --- a/src/scripts/node/create-item.lua +++ b/src/scripts/node/create-item.lua @@ -17,6 +17,7 @@ function configProperties (node) local properties = node.properties local media_class = properties ["media.class"] or "" local factory_name = properties ["factory.name"] or "" + local collection = CollectionManager.get_global_collection (node) -- ensure a media.type is set if not properties ["media.type"] then @@ -44,6 +45,7 @@ function configProperties (node) (factory_name == "api.alsa.pcm.sink" or factory_name == "api.bluez5.a2dp.sink") and Settings.get_boolean ("node.features.audio.mono") properties ["node.id"] = node ["bound-id"] + properties ["collection.name"] = collection and collection:get_name () or nil -- set the default media.role, if configured -- avoid Settings.get_string(), as it will parse the default "null" value @@ -61,69 +63,76 @@ AsyncEventHook { name = "node/create-item", interests = { EventInterest { - Constraint { "event.type", "=", "node-added" }, + Constraint { "event.type", "c", "node-added", "node-collected", "node-dropped" }, Constraint { "media.class", "#", "Stream/*", type = "pw-global" }, }, EventInterest { - Constraint { "event.type", "=", "node-added" }, + Constraint { "event.type", "c", "node-added", "node-collected", "node-dropped" }, Constraint { "media.class", "#", "Video/*", type = "pw-global" }, }, EventInterest { - Constraint { "event.type", "=", "node-added" }, + Constraint { "event.type", "c", "node-added", "node-collected", "node-dropped" }, Constraint { "media.class", "#", "Audio/*", type = "pw-global" }, Constraint { "wireplumber.is-virtual", "-", type = "pw" }, }, }, steps = { start = { - next = "register", + next = "none", execute = function (event, transition) local node = event:get_subject () local id = node.id - local item - local item_type - local media_class = node.properties ['media.class'] + local node_name = node.properties ['node.name'] + local collection = CollectionManager.get_global_collection (node) + local collection_name = collection and collection:get_name () or nil + + -- Just return if the item already exists with the same collection name + if items [id] and + items [id]:get_property ("collection.name") == collection_name then + transition:advance () + return + end + + -- Destroy the old item if any + if items [id] then + log:info (items [id], "destroying item for node " .. tostring (id)) + items [id]:remove () + items [id] = nil + end + + -- get the item type + local item_type = nil if string.find (media_class, "Audio") then item_type = "si-audio-adapter" else item_type = "si-node" end - log:info (node, "creating item for node -> " .. item_type) - -- create item - item = SessionItem (item_type) + local item = SessionItem (item_type) items [id] = item + log:info (item, "created item for node " .. tostring (id) .. ": " .. node_name) -- configure item if not item:configure (configProperties (node)) then - transition:return_error ("failed to configure item for node " - .. tostring (id)) + transition:return_error ("failed to configure item for node " .. tostring (id)) return end -- activate item item:activate (Features.ALL, function (_, e) if e then - transition:return_error ("failed to activate item: " - .. tostring (e)); - else - transition:advance () + transition:return_error ("failed to activate item for node " .. tostring (id) .. + ": " .. tostring (e)); + return end - end) - end, - }, - register = { - next = "none", - execute = function (event, transition) - local node = event:get_subject () - local bound_id = node ["bound-id"] - local item = items [node.id] - log:info (item, "activated item for node " .. tostring (bound_id)) - item:register () - transition:advance () + -- register item + log:info (item, "activated item for node " .. tostring (id) .. ": " .. node_name) + item:register () + transition:advance () + end) end, }, }, From 81f86f81324da6ec0c8aa2f3cb22a57ca31b7818 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 16 Dec 2025 13:13:03 -0500 Subject: [PATCH 12/17] scripts: Add 'device/create-alsa-loopback.lua' script This new script allows creating loopback filters for ALSA nodes matching a particular route using a JSON configuration file. See the provided example configuration file for more info. --- src/config/wireplumber.conf | 5 + .../wireplumber.conf.d.examples/alsa.conf | 68 +++ src/scripts/device/create-alsa-loopback.lua | 478 ++++++++++++++++++ 3 files changed, 551 insertions(+) create mode 100644 src/scripts/device/create-alsa-loopback.lua diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf index 85bfe2de..8385d8a2 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -579,10 +579,15 @@ wireplumber.components = [ name = device/autoswitch-bluetooth-profile.lua, type = script/lua provides = hooks.device.profile.autoswitch-bluetooth } + { + name = device/create-alsa-loopback.lua, type = script/lua + provides = hooks.device.profile.create-alsa-loopback + } { type = virtual, provides = policy.device.profile requires = [ hooks.device.profile.select, hooks.device.profile.autoswitch-bluetooth, + hooks.device.profile.create-alsa-loopback, hooks.device.profile.apply ] wants = [ hooks.device.profile.find-voice-call, hooks.device.profile.find-best, diff --git a/src/config/wireplumber.conf.d.examples/alsa.conf b/src/config/wireplumber.conf.d.examples/alsa.conf index 4eed6d66..d2930da1 100644 --- a/src/config/wireplumber.conf.d.examples/alsa.conf +++ b/src/config/wireplumber.conf.d.examples/alsa.conf @@ -129,3 +129,71 @@ monitor.alsa.rules = [ # } # } ] + +loopback.alsa.rules = [ + ## The list of ALSA loopback rules + + ## This rule example allows creating a loopback for the route device 3 of + ## all ALSA devices. + # { + # matches = [ + # { + # ## This matches all ALSA cards + # device.name = "~alsa_card.*" + # } + # ] + # actions = { + # ## MANDATORY: This action is used to create a loopback for a particular + # ## route device number. An ALSA loopback collection for this route + # ## device will also be created so that it has its own policy. + # create-loopback = { + # ## This creates a loopback for sub-device 3, which is the ALSA node + # ## that represents the route with device number 3. + # [ + # ## MANDATORY: The route device this loopback will be used with + # route-device = 3 + # + # ## OPTIONAL: Whether the loopback is dynamic or not. Dynamic loopbacks + # ## are only available if the current ALSA profile supports the given + # ## route device. + # dynamic = false + # + # ## OPTIONAL: Extra ALSA loopback node properties + # device-node-props = { + # card.profile.device = 3 + # node.virtual = false + # priority.session = 1000 + # } + # + # ## OPTIONAL: Extra stream loopback node properties + # stream-node-props = { + # node.virtual = false + # } + # ] + # } + # + # ## OPTIONAL: This action is used to collect extra nodes into the ALSA + # ## loopback collection, which can be useful if we want to include extra + # ## filter nodes. + # collect-nodes = [ + # { + # matches = [ + # { + # ## This matches an ALSA device filter node + # node.name = "filter-evice-node-name" + # } + # { + # ## This matches an ALSA stream filter node + # node.name = "filter-stream-node-name" + # } + # ] + # actions = { + # ## MANDATORY: This action is used to specify where to collect the + # ## matched nodes + # select-route-device = 3 + # } + # } + # ] + # } + # } +] diff --git a/src/scripts/device/create-alsa-loopback.lua b/src/scripts/device/create-alsa-loopback.lua new file mode 100644 index 00000000..bc3066fa --- /dev/null +++ b/src/scripts/device/create-alsa-loopback.lua @@ -0,0 +1,478 @@ +-- WirePlumber +-- +-- Copyright © 2025 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +-- This collect-global.lua script collects globals into collections. + +cutils = require ("common-utils") +log = Log.open_topic ("s-device") + +config = {} +config.rules = Conf.get_section_as_json ("loopback.alsa.rules", Json.Array {}) + +local alsa_loopback_ids = {} +local alsa_loopbacks = {} + +function getProfileLoopbackIds (profile) + local loopback_ids = {} + if type (profile.classes) == "table" and profile.classes.pod_type == "Struct" then + for _, p in ipairs (profile.classes) do + if type (p) == "table" and p.pod_type == "Struct" then + local i = 1 + while true do + local k, v = p [i], p [i+1] + i = i + 2 + if not k or not v then + break + end + if k == "card.profile.devices" and + type (v) == "table" and v.pod_type == "Array" then + for _, dev_id in ipairs (v) do + loopback_ids [dev_id + 1] = true + end + end + end + end + end + end + return loopback_ids +end + +AsyncEventHook { + name = "device/evaluate-loopbacks", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-params-changed" }, + Constraint { "event.subject.param-id", "=", "Profile" }, + Constraint { "device.api", "=", "alsa" }, + }, + }, + steps = { + start = { + next = "none", + execute = function (event, transition) + local source = event:get_source () + local device = event:get_subject () + local create_loopback_info = nil + local collect_node_rules = Json.Array {} + local loopback_info = {} + + -- Check if there are matching rules for this device + JsonUtils.match_rules (config.rules, device.properties, function (action, value) + if action == "create-loopback" then + create_loopback_info = value:parse () + elseif action == "collect-nodes" then + collect_node_rules = value + end + return true + end) + if create_loopback_info == nil then + transition:advance () + return + end + + -- Populate the loopback_info table from the create_loopback_info table + for _, info in pairs(create_loopback_info) do + local id = info ["route-device"] + if id ~= nil and tonumber (id) >= 0 then + loopback_info [tonumber (id) + 1] = {} + loopback_info [tonumber (id) + 1].dynamic = info ["dynamic"] or false + loopback_info [tonumber (id) + 1].device_node_props = info ["device-node-props"] or {} + loopback_info [tonumber (id) + 1].stream_node_props = info ["stream-node-props"] or {} + else + log:warning (device, + "Found create loopback info without valid route-device property. Ignoring...") + end + end + + -- Get routes information + device:enum_params ("EnumRoute", function (enum_route_it, e) + -- check for error + if e then + transition:return_error ("failed to enum routes: " + .. tostring (e)); + return + end + + -- Make sure the device is still valid + if (device:get_active_features() & Feature.Proxy.BOUND) == 0 then + transition:advance () + return + end + + -- Get device routes info + local routes_info = {} + for p in enum_route_it:iterate() do + local route = cutils.parseParam (p, "EnumRoute") + if not route then + goto skip_enum_route + end + + for _, id in ipairs(route.devices) do + if routes_info [id + 1] == nil then + routes_info [id + 1] = {} + end + routes_info [id + 1].available = route.available + routes_info [id + 1].priority = route.priority + routes_info [id + 1].direction = route.direction + routes_info [id + 1].name = route.name + routes_info [id + 1].media_class = + route.direction == "Input" and "Audio/Source" or "Audio/Sink" + end + + ::skip_enum_route:: + end + + -- Make sure we found route info for all configured loopbacks + local all_loopback_info_found = true + for id, _ in pairs(loopback_info) do + if routes_info [id] == nil then + all_loopback_info_found = false + break + end + end + if not all_loopback_info_found then + transition:return_error ( + "Could not find route info for configured ALSA loopback IDs"); + return + end + + -- Get the current profile + local profile = nil + for p in device:iterate_params ("Profile") do + profile = cutils.parseParam (p, "Profile") + break + end + assert (profile) + + -- Get current and old loopback Ids + local new_loopback_ids = getProfileLoopbackIds (profile) + local old_loopback_ids = alsa_loopback_ids [device.id] ~= nil and + alsa_loopback_ids [device.id] or {} + alsa_loopback_ids [device.id] = new_loopback_ids + + -- Create dynamic loopbacks + for id, _ in ipairs(new_loopback_ids) do + local info = loopback_info [id] + if old_loopback_ids [id] == nil and info ~= nil and info.dynamic then + local e = source:call ("create-event", "create-loopback", device, nil) + e:set_data ("loopback-id", id - 1) + e:set_data ("media-class", routes_info [id].media_class) + e:set_data ("device-node-props", info.device_node_props) + e:set_data ("stream-node-props", info.stream_node_props) + e:set_data ("collect-node-rules", collect_node_rules) + EventDispatcher.push_event (e) + end + end + + -- Destroy dynamic loopbacks + for id, _ in ipairs(old_loopback_ids) do + local info = loopback_info [id] + if new_loopback_ids [id] == nil and info ~= nil and info.dynamic then + local e = source:call ("create-event", "destroy-loopback", device, nil) + e:set_data ("loopback-id", id - 1) + e:set_data ("media-class", routes_info [id].media_class) + e:set_data ("device-node-props", info.device_node_props) + e:set_data ("stream-node-props", info.stream_node_props) + e:set_data ("collect-node-rules", collect_node_rules) + EventDispatcher.push_event (e) + end + end + + -- Create static loopbacks if never created before + for id, info in pairs(loopback_info) do + if not info.dynamic and + (alsa_loopbacks [device.id] == nil or alsa_loopbacks [device.id][id] == nil) then + local e = source:call ("create-event", "create-loopback", device, nil) + e:set_data ("loopback-id", id - 1) + e:set_data ("media-class", routes_info [id].media_class) + e:set_data ("device-node-props", info.device_node_props) + e:set_data ("stream-node-props", info.stream_node_props) + e:set_data ("collect-node-rules", collect_node_rules) + EventDispatcher.push_event (e) + end + end + + transition:advance () + end) + end + } + } +}:register () + +function CreateLoopback (device, device_media_class, loopback_id, conf_device_node_props, conf_stream_node_props) + local devide_id = device["bound-id"] + local device_props = device.properties + local loopback_name = device_props ["device.name"] .. "." .. tostring (loopback_id) + local stream_media_class = nil + local args = nil + + -- Set stream media class + if device_media_class == "Audio/Sink" then + stream_media_class = "Stream/Output/Audio" + elseif device_media_class == "Audio/Source" then + stream_media_class = "Stream/Input/Audio" + else + log:warning (device, "Device media class '" .. device_media_class .. + "' is not valid") + return nil + end + + -- Set stream props + local stream_raw_props = { + ["node.name"] = string.format ("alsa_loopback_stream.%s", loopback_name), + ["node.description"] = string.format ("ALSA Loopback stream for %s", loopback_name), + ["media.class"] = stream_media_class, + ["alsa.loopback.id"] = loopback_id, + ["device.id"] = devide_id, + ["node.passive"] = true, + ["node.dont-fallback"] = true, + ["node.linger"] = true, + } + for k, v in pairs (conf_stream_node_props) do + stream_raw_props [k] = v + end + local stream_props = Json.Object (stream_raw_props) + + -- Set device props + local devices_raw_props = { + ["node.name"] = string.format ("alsa_loopback_device.%s", loopback_name), + ["node.description"] = string.format ("ALSA Loopback device for %s", loopback_name), + ["media.class"] = device_media_class, + ["alsa.loopback.id"] = loopback_id, + ["device.id"] = devide_id, + } + for k, v in pairs (conf_device_node_props) do + devices_raw_props [k] = v + end + local device_props = Json.Object (devices_raw_props) + + -- Set args + if device_media_class == "Audio/Sink" then + args = Json.Object { + ["playback.props"] = stream_props, + ["capture.props"] = device_props + } + elseif device_media_class == "Audio/Source" then + args = Json.Object { + ["playback.props"] = device_props, + ["capture.props"] = stream_props + } + else + return nil + end + + -- Load loopback + return LocalModule("libpipewire-module-loopback", args:get_data(), {}) +end + +function getCollectionName (device_name, loopback_id) + return "alsa_loopback." .. device_name .. "." .. tostring (loopback_id) +end + +AsyncEventHook { + name = "device/create-loopback", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-loopback" }, + Constraint { "device.api", "=", "alsa" }, + }, + }, + steps = { + start = { + next = "none", + execute = function (event, transition) + local source = event:get_source () + local nodes_om = source:call ("get-object-manager", "node") + local device = event:get_subject () + local device_name = device:get_property ("device.name") + + -- Get the loopback ID + local loopback_id = event:get_data ("loopback-id") + if loopback_id == nil then + transition:return_error ("Event data does not have loopback ID"); + return + end + + -- Get the media class property + local media_class = event:get_data ("media-class") + if media_class == nil then + transition:return_error ("Event data does not have media class property"); + return + end + + -- Get the device properties + local device_node_props = event:get_data ("device-node-props") + if device_node_props == nil then + transition:return_error ("Event data does not have device properties"); + return + end + + -- Get the stream properties + local stream_node_props = event:get_data ("stream-node-props") + if stream_node_props == nil then + transition:return_error ("Event data does not have stream properties"); + return + end + + -- Get the collect node rules + local collect_node_rules = event:get_data ("collect-node-rules") + if collect_node_rules == nil then + transition:return_error ("Event data does not have collect node rules"); + return + end + + -- Create the collection for this loopback + local collection_name = getCollectionName (device_name, loopback_id) + CollectionManager.create_collection (collection_name, function (_, e) + -- Make sure the collection was created + if e ~= nil then + transition:return_error ("Failed to create collection '" .. + collection_name .. "': " .. tostring (e)); + return + end + log:info (device, "Created ALSA loopback collection for ID " .. + tostring (loopback_id) .. ": " .. collection_name) + + -- Create the loopback module + if alsa_loopbacks [device.id] == nil then + alsa_loopbacks [device.id] = {} + end + alsa_loopbacks [device.id][loopback_id] = CreateLoopback (device, + media_class, loopback_id, device_node_props, stream_node_props) + log:info (device, "Created loopback module for ID " .. + tostring (loopback_id)) + + -- Collect nodes to collection if any there are rules for them + for node in nodes_om:iterate () do + local node_name = node:get_property ("node.name") + JsonUtils.match_rules (collect_node_rules, node.properties, function (action, value) + if action == "select-route-device" and value:is_int () and value:parse () == loopback_id then + if not CollectionManager.collect_global (node, collection_name) then + log:warning (device, "Failed to collect node '" .. node_name .. + "' into '" .. collection_name .. "'") + else + log:info (device, "Collected node '" .. node_name .. "' into '" .. + collection_name .. "'") + end + end + end) + end + + transition:advance () + end) + end, + }, + }, +}:register () + +SimpleEventHook { + name = "device/destroy-loopback", + interests = { + EventInterest { + Constraint { "event.type", "=", "destroy-loopback" }, + Constraint { "device.api", "=", "alsa" }, + }, + }, + execute = function (event) + local device = event:get_subject () + + -- Get the loopback ID + local loopback_id = event:get_data ("loopback-id") + if loopback_id == nil then + log:warning (device, "Event data does not have loopback ID") + return + end + + -- Destroy loopback module + if alsa_loopbacks [device.id] == nil then + alsa_loopbacks [device.id] = {} + end + alsa_loopbacks [device.id][loopback_id] = nil + log:info (device, "Destroyed loopback module for ID " .. + tostring (loopback_id)) + end +}:register () + +SimpleEventHook { + name = "device/destroy-loopbacks", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-removed" }, + Constraint { "device.api", "=", "alsa" }, + }, + }, + execute = function (event) + local device = event:get_subject () + + -- Remove all loopbacks associated with this device + alsa_loopbacks [device.id] = nil + end +}:register () + +SimpleEventHook { + name = "device/collect-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-collection" }, + Constraint { "event.subject.type", "=", "node" }, + }, + }, + execute = function (event, transition) + local source = event:get_source () + local node = event:get_subject () + + -- Get the node device Id + local device_id = node.properties:get_int ("device.id") + if device_id == nil then + return + end + + -- Get the associated device + local devices_om = source:call ("get-object-manager", "device") + local device = devices_om:lookup { + Constraint { "bound-id", "=", device_id, type = "gobject" }, + } + if device == nil then + return + end + + -- Get the loopback ID + local loopback_id = node.properties:get_int ("card.profile.device") + if loopback_id == nil then + loopback_id = node.properties:get_int ("alsa.loopback.id") + if loopback_id == nil then + return + end + end + + -- Don't collect the ALSA loopback device node + local link_group = node:get_property ("node.link-group") + local media_class = node:get_property ("media.class") + if link_group ~= nil and link_group:find ("^loopback") and + not media_class:find ("^Stream/") then + return + end + + -- Check if there is a collection for this ALSA loopback + local device_name = device:get_property ("device.name") + local collection_name = getCollectionName (device_name, loopback_id) + if not CollectionManager.has_collection (collection_name) then + return + end + + -- Collect the node into the device collection + local node_name = node:get_property ("node.name") + if not CollectionManager.collect_global (node, collection_name) then + log:warning (device, "Failed to collect node '" .. node_name .. + "' into '" .. collection_name .. "'") + return + end + + log:info (device, "Collected node '" .. node_name .. "' into '" .. + collection_name .. "'") + end, +}:register () From 340a41e699a6b961fcee834aeeed1b2fb780e8d6 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 16 Dec 2025 14:34:08 -0500 Subject: [PATCH 13/17] scripts/default-nodes: Restrict to nodes that are not part of any collection Nodes that are part of a collection are handled separately. --- src/scripts/default-nodes/apply-default-node.lua | 5 ++++- src/scripts/default-nodes/find-selected-default-node.lua | 5 ++++- src/scripts/default-nodes/rescan.lua | 6 +++++- src/scripts/default-nodes/state-default-nodes.lua | 2 ++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/scripts/default-nodes/apply-default-node.lua b/src/scripts/default-nodes/apply-default-node.lua index 608118b7..56ec8d77 100644 --- a/src/scripts/default-nodes/apply-default-node.lua +++ b/src/scripts/default-nodes/apply-default-node.lua @@ -23,7 +23,10 @@ SimpleEventHook { local selected_node = event:get_data ("selected-node") local om = source:call ("get-object-manager", "metadata") - local metadata = om:lookup { Constraint { "metadata.name", "=", "default" } } + local metadata = om:lookup { + Constraint { "metadata.name", "=", "default" }, + Constraint { "wireplumber.collection", "-" }, + } if metadata == nil then return end diff --git a/src/scripts/default-nodes/find-selected-default-node.lua b/src/scripts/default-nodes/find-selected-default-node.lua index 8ece52cd..e9b60d12 100644 --- a/src/scripts/default-nodes/find-selected-default-node.lua +++ b/src/scripts/default-nodes/find-selected-default-node.lua @@ -37,7 +37,10 @@ SimpleEventHook { local props = event:get_properties () local def_node_type = props ["default-node.type"] local metadata_om = source:call ("get-object-manager", "metadata") - local metadata = metadata_om:lookup { Constraint { "metadata.name", "=", "default" } } + local metadata = metadata_om:lookup { + Constraint { "metadata.name", "=", "default" }, + Constraint { "wireplumber.collection", "-" }, + } local obj = metadata:find (0, "default.configured." .. def_node_type) if not obj then diff --git a/src/scripts/default-nodes/rescan.lua b/src/scripts/default-nodes/rescan.lua index 5ac30c1b..98a2bd09 100644 --- a/src/scripts/default-nodes/rescan.lua +++ b/src/scripts/default-nodes/rescan.lua @@ -20,11 +20,13 @@ SimpleEventHook { Constraint { "event.type", "c", "session-item-added", "session-item-removed" }, Constraint { "event.session-item.interface", "=", "linkable" }, Constraint { "media.class", "#", "Audio/*" }, + Constraint { "collection.name", "-" }, }, EventInterest { Constraint { "event.type", "c", "session-item-added", "session-item-removed" }, Constraint { "event.session-item.interface", "=", "linkable" }, Constraint { "media.class", "#", "Video/*" }, + Constraint { "collection.name", "-" }, }, EventInterest { Constraint { "event.type", "=", "metadata-changed" }, @@ -32,6 +34,7 @@ SimpleEventHook { Constraint { "event.subject.key", "c", "default.configured.audio.sink", "default.configured.audio.source", "default.configured.video.source" }, + Constraint { "wireplumber.collection", "-" }, }, EventInterest { Constraint { "event.type", "=", "device-params-changed"}, @@ -96,13 +99,14 @@ function collectAvailableNodes (si_om, devices_om, port_direction, media_classes for linkable in si_om:iterate { type = "SiLinkable", Constraint { "media.class", "c", table.unpack (media_classes) }, + Constraint { "collection.name", "-" }, } do local node = linkable:get_associated_proxy ("node") local node_props = node.properties -- check that the node has ports in the requested direction if not node:lookup_port { - Constraint { "port.direction", "=", port_direction } + Constraint { "port.direction", "=", port_direction }, } then goto next_linkable end diff --git a/src/scripts/default-nodes/state-default-nodes.lua b/src/scripts/default-nodes/state-default-nodes.lua index 1f379daf..2fd021b7 100644 --- a/src/scripts/default-nodes/state-default-nodes.lua +++ b/src/scripts/default-nodes/state-default-nodes.lua @@ -73,6 +73,7 @@ store_configured_default_nodes_hook = SimpleEventHook { Constraint { "event.subject.key", "c", "default.configured.audio.sink", "default.configured.audio.source", "default.configured.video.source" }, + Constraint { "wireplumber.collection", "-" }, }, }, execute = function (event) @@ -119,6 +120,7 @@ metadata_added_hook = SimpleEventHook { EventInterest { Constraint { "event.type", "=", "metadata-added" }, Constraint { "metadata.name", "=", "default" }, + Constraint { "wireplumber.collection", "-" }, }, }, execute = function (event) From 0bbf7f9f8117e7d580bbbb3167fb94ada1ab594b Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 16 Dec 2025 14:35:39 -0500 Subject: [PATCH 14/17] filter-utils.lua: Restrict smart filters to nodes that are not part of any collection Smart filters are not meant to be used with collection specific policy. --- src/scripts/lib/filter-utils.lua | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/scripts/lib/filter-utils.lua b/src/scripts/lib/filter-utils.lua index 3a638468..1e185f98 100644 --- a/src/scripts/lib/filter-utils.lua +++ b/src/scripts/lib/filter-utils.lua @@ -129,7 +129,10 @@ local function getFilterSmartTarget (metadata, node, om) -- Find target local target = nil - for si_target in om:iterate { type = "SiLinkable" } do + for si_target in om:iterate { + type = "SiLinkable", + Constraint { "collection.name", "-" }, + } do local n_target = si_target:get_associated_proxy ("node") if n_target == nil then goto skip_target @@ -291,7 +294,10 @@ local function rescanFilters (om, metadata_om) Log.info ("rescanning filters...") - for si in om:iterate { type = "SiLinkable" } do + for si in om:iterate { + type = "SiLinkable", + Constraint { "collection.name", "-" }, + } do local filter = {} local n = si:get_associated_proxy ("node") @@ -337,7 +343,8 @@ local function rescanFilters (om, metadata_om) filter.stream_si = om:lookup { type = "SiLinkable", Constraint { "node.link-group", "=", filter.link_group }, - Constraint { "media.class", "#", "Stream/*", type = "pw-global" } + Constraint { "media.class", "#", "Stream/*", type = "pw-global" }, + Constraint { "collection.name", "-" }, } -- Add the filter to the list sorted by before and after From f66d91a842a668950f2836f107ce02c96a3285d5 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Thu, 15 Jan 2026 12:22:12 -0500 Subject: [PATCH 15/17] linking: Make sure we don't handle metadata collections Since collections are also metadata objects, this avoids possible conflics if some collection metadatas have matching names. --- src/scripts/linking/rescan-media-role-links.lua | 2 ++ src/scripts/linking/rescan.lua | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/scripts/linking/rescan-media-role-links.lua b/src/scripts/linking/rescan-media-role-links.lua index f2747e73..14419c75 100644 --- a/src/scripts/linking/rescan-media-role-links.lua +++ b/src/scripts/linking/rescan-media-role-links.lua @@ -68,6 +68,7 @@ function getSuspendPlaybackFromMetadata (om) local metadata = om:lookup { type = "metadata", Constraint { "metadata.name", "=", "default" }, + Constraint { "wireplumber.collection", "-" }, } if metadata then local value = metadata:find(0, "suspend.playback") @@ -92,6 +93,7 @@ AsyncEventHook { Constraint { "event.type", "=", "metadata-changed" }, Constraint { "metadata.name", "=", "default" }, Constraint { "event.subject.key", "=", "suspend.playback" }, + Constraint { "wireplumber.collection", "-" }, } }, steps = { diff --git a/src/scripts/linking/rescan.lua b/src/scripts/linking/rescan.lua index db59d6a8..66367c3f 100644 --- a/src/scripts/linking/rescan.lua +++ b/src/scripts/linking/rescan.lua @@ -229,11 +229,13 @@ SimpleEventHook { Constraint { "metadata.name", "=", "default" }, Constraint { "event.subject.key", "c", "default.audio.source", "default.audio.sink", "default.video.source" }, + Constraint { "wireplumber.collection", "-" }, }, -- on any "filters" metadata changed EventInterest { Constraint { "event.type", "=", "metadata-changed" }, Constraint { "metadata.name", "=", "filters" }, + Constraint { "wireplumber.collection", "-" }, }, }, execute = function (event) @@ -307,6 +309,7 @@ function handleMoveSetting (enable) Constraint { "event.type", "=", "metadata-changed" }, Constraint { "metadata.name", "=", "default" }, Constraint { "event.subject.key", "c", "target.object", "target.node" }, + Constraint { "wireplumber.collection", "-" }, }, }, execute = function (event) From 6634ce8f179b92db4cf94812078ebf194647d965 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Thu, 15 Jan 2026 12:31:33 -0500 Subject: [PATCH 16/17] Restrict the policy to session items that are not part of any collection This will avoid conflics with collection specific policies. The policy scripts have also been moved inside the 'default' sub-directory. --- src/config/wireplumber.conf | 48 +++++++++---------- .../{ => default}/find-audio-group-target.lua | 2 + .../{ => default}/find-best-target.lua | 2 + .../{ => default}/find-default-target.lua | 1 + .../{ => default}/find-defined-target.lua | 7 ++- .../{ => default}/find-filter-target.lua | 1 + .../find-media-role-sink-target.lua | 18 ++++--- .../{ => default}/find-media-role-target.lua | 2 + .../find-user-target.lua.example | 1 + .../{ => default}/get-filter-from-target.lua | 1 + tests/script-tester.c | 12 ++--- 11 files changed, 57 insertions(+), 38 deletions(-) rename src/scripts/linking/{ => default}/find-audio-group-target.lua (96%) rename src/scripts/linking/{ => default}/find-best-target.lua (97%) rename src/scripts/linking/{ => default}/find-default-target.lua (97%) rename src/scripts/linking/{ => default}/find-defined-target.lua (95%) rename src/scripts/linking/{ => default}/find-filter-target.lua (98%) rename src/scripts/linking/{ => default}/find-media-role-sink-target.lua (84%) rename src/scripts/linking/{ => default}/find-media-role-target.lua (95%) rename src/scripts/linking/{ => default}/find-user-target.lua.example (95%) rename src/scripts/linking/{ => default}/get-filter-from-target.lua (98%) diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf index 8385d8a2..8003e02e 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -709,36 +709,36 @@ wireplumber.components = [ requires = [ hooks.linking.rescan ] } { - name = linking/find-media-role-target.lua, type = script/lua - provides = hooks.linking.target.find-media-role + name = linking/default/find-media-role-target.lua, type = script/lua + provides = hooks.linking.default.target.find-media-role } { - name = linking/find-defined-target.lua, type = script/lua - provides = hooks.linking.target.find-defined + name = linking/default/find-defined-target.lua, type = script/lua + provides = hooks.linking.default.target.find-defined } { - name = linking/find-audio-group-target.lua, type = script/lua - provides = hooks.linking.target.find-audio-group + name = linking/default/find-audio-group-target.lua, type = script/lua + provides = hooks.linking.default.target.find-audio-group requires = [ node.audio-group ] } { - name = linking/find-filter-target.lua, type = script/lua - provides = hooks.linking.target.find-filter + name = linking/default/find-filter-target.lua, type = script/lua + provides = hooks.linking.default.target.find-filter requires = [ metadata.filters ] } { - name = linking/find-default-target.lua, type = script/lua - provides = hooks.linking.target.find-default + name = linking/default/find-default-target.lua, type = script/lua + provides = hooks.linking.default.target.find-default requires = [ api.default-nodes ] } { - name = linking/find-best-target.lua, type = script/lua - provides = hooks.linking.target.find-best + name = linking/default/find-best-target.lua, type = script/lua + provides = hooks.linking.default.target.find-best requires = [ metadata.filters ] } { - name = linking/get-filter-from-target.lua, type = script/lua - provides = hooks.linking.target.get-filter-from + name = linking/default/get-filter-from-target.lua, type = script/lua + provides = hooks.linking.default.target.get-filter-from requires = [ metadata.filters ] } { @@ -762,13 +762,13 @@ wireplumber.components = [ hooks.linking.target.prepare-link, hooks.linking.target.link ] wants = [ hooks.linking.rescan-on-linkable, - hooks.linking.target.find-media-role, - hooks.linking.target.find-defined, - hooks.linking.target.find-audio-group, - hooks.linking.target.find-filter, - hooks.linking.target.find-default, - hooks.linking.target.find-best, - hooks.linking.target.get-filter-from, + hooks.linking.default.target.find-media-role, + hooks.linking.default.target.find-defined, + hooks.linking.default.target.find-audio-group, + hooks.linking.default.target.find-filter, + hooks.linking.default.target.find-default, + hooks.linking.default.target.find-best, + hooks.linking.default.target.get-filter-from, hooks.linking.pause-playback ] } @@ -784,15 +784,15 @@ wireplumber.components = [ requires = [ hooks.linking.role-based.rescan ] } { - name = linking/find-media-role-sink-target.lua, type = script/lua - provides = hooks.linking.target.find-media-role-sink + name = linking/default/find-media-role-sink-target.lua, type = script/lua + provides = hooks.linking.default.target.find-media-role-sink } { type = virtual, provides = policy.linking.role-based requires = [ policy.linking.standard, hooks.linking.role-based.rescan, hooks.node.role-based.default-volume, - hooks.linking.target.find-media-role-sink ] + hooks.linking.default.target.find-media-role-sink ] } ## Standard policy definition diff --git a/src/scripts/linking/find-audio-group-target.lua b/src/scripts/linking/default/find-audio-group-target.lua similarity index 96% rename from src/scripts/linking/find-audio-group-target.lua rename to src/scripts/linking/default/find-audio-group-target.lua index ffd2e182..3233439d 100644 --- a/src/scripts/linking/find-audio-group-target.lua +++ b/src/scripts/linking/default/find-audio-group-target.lua @@ -18,6 +18,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -56,6 +57,7 @@ SimpleEventHook { Constraint { "item.node.type", "=", "device" }, Constraint { "item.node.direction", "=", target_direction }, Constraint { "media.type", "=", si_props ["media.type"] }, + Constraint { "collection.name", "-" }, } do target_node = target:get_associated_proxy ("node") target_node_props = target_node.properties diff --git a/src/scripts/linking/find-best-target.lua b/src/scripts/linking/default/find-best-target.lua similarity index 97% rename from src/scripts/linking/find-best-target.lua rename to src/scripts/linking/default/find-best-target.lua index 46b31507..4cc332ed 100644 --- a/src/scripts/linking/find-best-target.lua +++ b/src/scripts/linking/default/find-best-target.lua @@ -21,6 +21,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -46,6 +47,7 @@ SimpleEventHook { Constraint { "item.node.type", "=", "device" }, Constraint { "item.node.direction", "=", target_direction }, Constraint { "media.type", "=", si_props ["media.type"] }, + Constraint { "collection.name", "-" }, } do local target_props = target.properties local target_node_id = target_props ["node.id"] diff --git a/src/scripts/linking/find-default-target.lua b/src/scripts/linking/default/find-default-target.lua similarity index 97% rename from src/scripts/linking/find-default-target.lua rename to src/scripts/linking/default/find-default-target.lua index 3caaf4f6..ce721f15 100644 --- a/src/scripts/linking/find-default-target.lua +++ b/src/scripts/linking/default/find-default-target.lua @@ -19,6 +19,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) diff --git a/src/scripts/linking/find-defined-target.lua b/src/scripts/linking/default/find-defined-target.lua similarity index 95% rename from src/scripts/linking/find-defined-target.lua rename to src/scripts/linking/default/find-defined-target.lua index 1dfb6d4b..dcd53e43 100644 --- a/src/scripts/linking/find-defined-target.lua +++ b/src/scripts/linking/default/find-defined-target.lua @@ -19,6 +19,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -75,12 +76,16 @@ SimpleEventHook { target = om:lookup { type = "SiLinkable", Constraint { target_key, "=", target_value }, + Constraint { "collection.name", "-" }, } if target and lutils.canLink (si_props, target) then target_picked = true end elseif target_value then - for lnkbl in om:iterate { type = "SiLinkable" } do + for lnkbl in om:iterate { + type = "SiLinkable", + Constraint { "collection.name", "-" }, + } do local target_props = lnkbl.properties if (target_props ["node.name"] == target_value or target_props ["object.path"] == target_value) and diff --git a/src/scripts/linking/find-filter-target.lua b/src/scripts/linking/default/find-filter-target.lua similarity index 98% rename from src/scripts/linking/find-filter-target.lua rename to src/scripts/linking/default/find-filter-target.lua index b82879ca..719648c0 100644 --- a/src/scripts/linking/find-filter-target.lua +++ b/src/scripts/linking/default/find-filter-target.lua @@ -38,6 +38,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) diff --git a/src/scripts/linking/find-media-role-sink-target.lua b/src/scripts/linking/default/find-media-role-sink-target.lua similarity index 84% rename from src/scripts/linking/find-media-role-sink-target.lua rename to src/scripts/linking/default/find-media-role-sink-target.lua index d6ab4c38..2b22543b 100644 --- a/src/scripts/linking/find-media-role-sink-target.lua +++ b/src/scripts/linking/default/find-media-role-sink-target.lua @@ -19,6 +19,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -45,6 +46,7 @@ SimpleEventHook { type = "SiLinkable", Constraint { "media.class", "=", "Audio/Sink" }, Constraint { "node.link-group", "=", link_group }, + Constraint { "collection.name", "-" }, } if input_node == nil then @@ -62,20 +64,22 @@ SimpleEventHook { type = "SiLinkable", Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, Constraint { "node.name", "=", target_name }, + Constraint { "collection.name", "-" }, } if si_target == nil then - si_target = om:lookup { - type = "SiLinkable", - Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, - Constraint { "node.nick", "=", target_name }, - } + si_target = om:lookup { + type = "SiLinkable", + Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, + Constraint { "node.nick", "=", target_name }, + Constraint { "collection.name", "-" }, + } end if si_target then - log:info (si, + log:info (si, string.format ("... role based sink target picked: %s (%s)", tostring (si_target.properties ["node.name"]), tostring (si_target.properties ["node.id"]))) - event:set_data ("target", si_target) + event:set_data ("target", si_target) end end }:register () diff --git a/src/scripts/linking/find-media-role-target.lua b/src/scripts/linking/default/find-media-role-target.lua similarity index 95% rename from src/scripts/linking/find-media-role-target.lua rename to src/scripts/linking/default/find-media-role-target.lua index d654b0c2..4c0682a8 100644 --- a/src/scripts/linking/find-media-role-target.lua +++ b/src/scripts/linking/default/find-media-role-target.lua @@ -18,6 +18,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -41,6 +42,7 @@ SimpleEventHook { Constraint { "item.node.direction", "=", target_direction }, Constraint { "device.intended-roles", "+" }, Constraint { "media.type", "=", si_props["media.type"] }, + Constraint { "collection.name", "-" }, } do local roles_json = si_target.properties["device.intended-roles"] diff --git a/src/scripts/linking/find-user-target.lua.example b/src/scripts/linking/default/find-user-target.lua.example similarity index 95% rename from src/scripts/linking/find-user-target.lua.example rename to src/scripts/linking/default/find-user-target.lua.example index 723913c5..8f0836fd 100644 --- a/src/scripts/linking/find-user-target.lua.example +++ b/src/scripts/linking/default/find-user-target.lua.example @@ -15,6 +15,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) diff --git a/src/scripts/linking/get-filter-from-target.lua b/src/scripts/linking/default/get-filter-from-target.lua similarity index 98% rename from src/scripts/linking/get-filter-from-target.lua rename to src/scripts/linking/default/get-filter-from-target.lua index 8f52191c..f98951f4 100644 --- a/src/scripts/linking/get-filter-from-target.lua +++ b/src/scripts/linking/default/get-filter-from-target.lua @@ -22,6 +22,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) diff --git a/tests/script-tester.c b/tests/script-tester.c index e89e76b9..75b9d2d4 100644 --- a/tests/script-tester.c +++ b/tests/script-tester.c @@ -240,12 +240,12 @@ load_components (ScriptRunnerFixture *f, gconstpointer argv) load_component (f, "node/create-item.lua", "script/lua"); - load_component (f, "linking/find-best-target.lua", "script/lua"); - load_component (f, "linking/find-default-target.lua", "script/lua"); - load_component (f, "linking/find-defined-target.lua", "script/lua"); - load_component (f, "linking/find-filter-target.lua", "script/lua"); - load_component (f, "linking/find-media-role-target.lua", "script/lua"); - load_component (f, "linking/get-filter-from-target.lua", "script/lua"); + load_component (f, "linking/default/find-best-target.lua", "script/lua"); + load_component (f, "linking/default/find-default-target.lua", "script/lua"); + load_component (f, "linking/default/find-defined-target.lua", "script/lua"); + load_component (f, "linking/default/find-filter-target.lua", "script/lua"); + load_component (f, "linking/default/find-media-role-target.lua", "script/lua"); + load_component (f, "linking/default/get-filter-from-target.lua", "script/lua"); load_component (f, "linking/link-target.lua", "script/lua"); load_component (f, "linking/prepare-link.lua", "script/lua"); load_component (f, "linking/rescan.lua", "script/lua"); From 13e07b101e5679e4eaf38c931d1f8471034d8d24 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 17 Dec 2025 09:38:15 -0500 Subject: [PATCH 17/17] linking: Add ALSA loopback policy scripts This policy script is in charge of linking all nodes from all ALSA loopback collections. --- src/config/wireplumber.conf | 10 ++ .../alsa-loopback/find-alsa-target.lua | 102 ++++++++++++++++++ .../alsa-loopback/find-filter-target.lua | 97 +++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 src/scripts/linking/alsa-loopback/find-alsa-target.lua create mode 100644 src/scripts/linking/alsa-loopback/find-filter-target.lua diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf index 8003e02e..231e552f 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -741,6 +741,14 @@ wireplumber.components = [ provides = hooks.linking.default.target.get-filter-from requires = [ metadata.filters ] } + { + name = linking/alsa-loopback/find-filter-target.lua, type = script/lua + provides = hooks.linking.alsa-loopback.target.find-filter + } + { + name = linking/alsa-loopback/find-alsa-target.lua, type = script/lua + provides = hooks.linking.alsa-loopback.target.find-alsa + } { name = linking/prepare-link.lua, type = script/lua provides = hooks.linking.target.prepare-link @@ -769,6 +777,8 @@ wireplumber.components = [ hooks.linking.default.target.find-default, hooks.linking.default.target.find-best, hooks.linking.default.target.get-filter-from, + hooks.linking.alsa-loopback.target.find-filter, + hooks.linking.alsa-loopback.target.find-alsa, hooks.linking.pause-playback ] } diff --git a/src/scripts/linking/alsa-loopback/find-alsa-target.lua b/src/scripts/linking/alsa-loopback/find-alsa-target.lua new file mode 100644 index 00000000..051ee561 --- /dev/null +++ b/src/scripts/linking/alsa-loopback/find-alsa-target.lua @@ -0,0 +1,102 @@ +-- WirePlumber +-- +-- Copyright © 2025 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Finds the hardware target for a linkable in 'alsa_loopback.*' collection + +lutils = require ("linking-utils") +cutils = require ("common-utils") +log = Log.open_topic ("s-linking") + +SimpleEventHook { + name = "linking/alsa-loopback/find-alsa-target", + before = "linking/prepare-link", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "#", "alsa_loopback.*" }, + }, + }, + execute = function (event) + local source, om, si, si_props, si_flags, target = + lutils:unwrap_select_target_event (event) + + -- Bypass the hook if the target is already picked up + if target then + return + end + + -- Get the collection name + local collection_name = si_props["collection.name"] + assert (collection_name) + + local target_direction = cutils.getTargetDirection (si_props) + local target_picked = nil + local target_can_passthrough = false + local target_priority = 0 + + log:info (string.format ("handling item %d: %s (%s)", si.id, + si_props ["node.name"], si_props ["node.id"])) + + -- Find the hardware target (session items without node.link-group property) + -- matching the collection name + for target in om:iterate { + type = "SiLinkable", + Constraint { "item.node.type", "=", "device" }, + Constraint { "item.node.direction", "=", target_direction }, + Constraint { "media.type", "=", si_props ["media.type"] }, + Constraint { "collection.name", "=", collection_name }, + Constraint { "node.link-group", "-" }, + } do + local target_props = target.properties + local priority = tonumber (target_props ["priority.session"]) or 0 + + log:debug (string.format ("Looking at: %s (%s)", + target_props ["node.name"], target_props ["node.id"])) + + if not lutils.canLink (si_props, target) then + log:debug ("... cannot link, skip linkable") + goto skip_linkable + end + + local passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if not passthrough_compatible then + log:debug ("... passthrough is not compatible, skip linkable") + goto skip_linkable + end + + if target_picked == nil or priority > target_priority then + log:debug ("... picked") + target_picked = target + target_can_passthrough = can_passthrough + target_priority = priority + end + + ::skip_linkable:: + end + + local can_passthrough, passthrough_compatible + if target then + passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if lutils.canLink (si_props, target) and passthrough_compatible then + target_picked = true + end + end + + if target_picked then + log:info (si, + string.format ("... hardware target picked: %s (%s), can_passthrough:%s", + target_picked.properties ["node.name"], + target_picked.properties ["node.id"], + tostring (target_can_passthrough))) + si_flags.can_passthrough = target_can_passthrough + event:set_data ("target", target_picked) + else + event:stop_processing () + end + end +}:register () diff --git a/src/scripts/linking/alsa-loopback/find-filter-target.lua b/src/scripts/linking/alsa-loopback/find-filter-target.lua new file mode 100644 index 00000000..2296efe8 --- /dev/null +++ b/src/scripts/linking/alsa-loopback/find-filter-target.lua @@ -0,0 +1,97 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Finds the filter target for a linkable in 'alsa_loopback.*' collection + +lutils = require ("linking-utils") +cutils = require ("common-utils") +log = Log.open_topic ("s-linking") + +SimpleEventHook { + name = "linking/alsa-loopback/find-filter-target", + before = "linking/alsa-loopback/find-alsa-target", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "#", "alsa_loopback.*" }, + }, + }, + execute = function (event) + local source, om, si, si_props, si_flags, target = + lutils:unwrap_select_target_event (event) + + -- Bypass the hook if the target is already picked up + if target then + return + end + + -- Get the collection name + local collection_name = si_props["collection.name"] + assert (collection_name) + + local target_direction = cutils.getTargetDirection (si_props) + local target_picked = nil + local target_can_passthrough = false + + log:info (string.format ("handling item %d: %s (%s)", si.id, + si_props ["node.name"], si_props ["node.id"])) + + -- Find the first filter target (session items with node.link-group property + -- that is not an alsa loopback item) matching the collection name + for target in om:iterate { + type = "SiLinkable", + Constraint { "item.node.type", "=", "device" }, + Constraint { "item.node.direction", "=", target_direction }, + Constraint { "media.type", "=", si_props ["media.type"] }, + Constraint { "collection.name", "=", collection_name }, + Constraint { "node.link-group", "+" }, + Constraint { "alsa.loopback.id", "-" }, + } do + local target_props = target.properties + + log:debug (string.format ("Looking at: %s (%s)", + target_props ["node.name"], target_props ["node.id"])) + + if not lutils.canLink (si_props, target) then + log:debug ("... cannot link, skip linkable") + goto skip_linkable + end + + local passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if not passthrough_compatible then + log:debug ("... passthrough is not compatible, skip linkable") + goto skip_linkable + end + + log:debug ("... picked") + target_picked = target + target_can_passthrough = can_passthrough + break + + ::skip_linkable:: + end + + local can_passthrough, passthrough_compatible + if target then + passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if lutils.canLink (si_props, target) and passthrough_compatible then + target_picked = true + end + end + + if target_picked then + log:info (si, + string.format ("... filter target picked: %s (%s), can_passthrough:%s", + target_picked.properties ["node.name"], + target_picked.properties ["node.id"], + tostring (target_can_passthrough))) + si_flags.can_passthrough = target_can_passthrough + event:set_data ("target", target_picked) + end + end +}:register ()