From c3ebd79f009a638ee791105beeacbf3e8918732e Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Thu, 4 Dec 2025 10:53:22 -0500 Subject: [PATCH 01/16] 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 9aacc624..f7780a0c 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', @@ -48,6 +49,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 3e010f96..8b62837b 100644 --- a/lib/wp/wp.h +++ b/lib/wp/wp.h @@ -47,6 +47,7 @@ #include "wpversion.h" #include "factory.h" #include "settings.h" +#include "collection.h" G_BEGIN_DECLS From 6efc077a5a56ff973b3388e1a46de4c225bab7ae Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Fri, 5 Dec 2025 14:29:05 -0500 Subject: [PATCH 02/16] 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 f7780a0c..ecadda0d 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', @@ -50,6 +51,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 8b62837b..abc0b317 100644 --- a/lib/wp/wp.h +++ b/lib/wp/wp.h @@ -48,6 +48,7 @@ #include "factory.h" #include "settings.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 511d85177da5d3bb3d2d2ded7ec3976786d1b4f9 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Sun, 7 Dec 2025 10:59:41 -0500 Subject: [PATCH 03/16] 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 ee1aa1d6..ac3d422b 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 efa1f9e1712aecbb796c6ef82ad2e194d37d4899 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Mon, 8 Dec 2025 16:05:20 -0500 Subject: [PATCH 04/16] 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 e059a532..15f149bd 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"); @@ -1976,6 +2140,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 2941151d1a44918ac62e78359bf5872feb810a19 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 10 Dec 2025 12:06:45 -0500 Subject: [PATCH 05/16] 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 019f354f..8108b9cb 100644 --- a/modules/module-lua-scripting/api/api.c +++ b/modules/module-lua-scripting/api/api.c @@ -2167,6 +2167,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 @@ -2617,6 +2664,252 @@ static const luaL_Reg event_dispatcher_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 @@ -3083,6 +3376,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); @@ -3144,6 +3440,8 @@ wp_lua_scripting_api_init (lua_State *L) NULL, iterator_funcs); wplua_register_type_methods (L, WP_TYPE_PROPERTIES, properties_new, properties_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 fe886bc3..db2b5d16 100644 --- a/modules/module-lua-scripting/api/api.lua +++ b/modules/module-lua-scripting/api/api.lua @@ -214,6 +214,7 @@ SANDBOX_EXPORT = { LocalModule = WpImplModule_new, ImplMetadata = WpImplMetadata_new, Settings = WpSettings, + CollectionManager = WpCollectionManager, Conf = WpConf, JsonUtils = JsonUtils, ProcUtils = ProcUtils, From b8a6e9fa9a6a31519dc31161868ab3972858969b Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 10 Dec 2025 14:55:24 -0500 Subject: [PATCH 06/16] 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 fba868b8..f85ca080 100644 --- a/modules/module-standard-event-source.c +++ b/modules/module-standard-event-source.c @@ -350,6 +350,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 8e582aec1171e54603fb4be7f3bb8fee2d52a168 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 31 Dec 2025 10:39:27 -0500 Subject: [PATCH 07/16] 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 f85ca080..eb8e0c71 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}; @@ -307,6 +308,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, @@ -406,6 +427,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++) { @@ -448,6 +478,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 8da63d79afecec6fab1a4052171a2632e7062acf Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Thu, 11 Dec 2025 12:40:32 -0500 Subject: [PATCH 08/16] event: Add 'collection.name' property if subject is part of a collection This makes it easy to define hooks for globals in specific collections. --- lib/wp/event.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/wp/event.c b/lib/wp/event.c index cd3db905..0c489983 100644 --- a/lib/wp/event.c +++ b/lib/wp/event.c @@ -11,6 +11,7 @@ #include "event-hook.h" #include "log.h" #include "proxy.h" +#include "collection-manager.h" #include #include @@ -104,6 +105,23 @@ wp_event_new (const gchar * type, gint priority, WpProperties * properties, wp_properties_update (self->properties, subj_props); } } + + /* Add collection name */ + if (WP_IS_GLOBAL_PROXY (self->subject)) { + g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self->subject)); + if (core) { + g_autoptr (WpCollectionManager) cm = NULL; + cm = wp_collection_manager_find (core, NULL); + if (cm) { + g_autoptr (WpCollection) c = + wp_collection_manager_get_global_collection (cm, + WP_GLOBAL_PROXY (self->subject)); + if (c) + wp_properties_set (self->properties, "collection.name", + wp_collection_get_name (c)); + } + } + } } wp_properties_set (self->properties, "event.type", type); From acc9092206422cdd4fd57d7680c2f45c320b96b6 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Mon, 15 Dec 2025 16:32:36 -0500 Subject: [PATCH 09/16] 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 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/module-standard-event-source.c b/modules/module-standard-event-source.c index eb8e0c71..c57cd427 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; else if (!g_strcmp0 (event_type, "rescan-for-default-nodes")) return -490; @@ -210,7 +211,8 @@ 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, "create-") || + g_str_has_prefix(event_type, "destroy-")) return TRUE; return FALSE; From 59e6f2ac0155b55b004604d72a0f510a3682be11 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 17 Dec 2025 12:10:08 -0500 Subject: [PATCH 10/16] 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 86a86208e9af8c539f787764f46020c8580935ef Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 16 Dec 2025 13:13:03 -0500 Subject: [PATCH 11/16] 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 ac3d422b..db4d5f17 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -556,10 +556,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 e1ec552b09ed1b7c3200d220bb3f98600e160106 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 16 Dec 2025 14:34:08 -0500 Subject: [PATCH 12/16] 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 4cac6b35b0c6240f3f71eae26602c1c6f77b1e0a Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 16 Dec 2025 14:35:39 -0500 Subject: [PATCH 13/16] 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 f27d18ece0d7ad03c2d43ff5410549e31c5aa8e2 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Thu, 15 Jan 2026 12:22:12 -0500 Subject: [PATCH 14/16] 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 1cae5b86..aeb172dd 100644 --- a/src/scripts/linking/rescan.lua +++ b/src/scripts/linking/rescan.lua @@ -234,11 +234,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) @@ -312,6 +314,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 ab803b9c43cf3a37f2e6ec33276264686380c891 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Thu, 15 Jan 2026 12:31:33 -0500 Subject: [PATCH 15/16] 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 db4d5f17..994fe4e1 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -681,36 +681,36 @@ wireplumber.components = [ provides = 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 ] } { @@ -733,13 +733,13 @@ wireplumber.components = [ requires = [ hooks.linking.rescan, hooks.linking.target.prepare-link, hooks.linking.target.link ] - wants = [ 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, + wants = [ 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 ] } @@ -755,15 +755,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 88713c85fcc46485089713d46f7c1c479602990d Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 17 Dec 2025 09:38:15 -0500 Subject: [PATCH 16/16] 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 994fe4e1..d4addf67 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -713,6 +713,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 @@ -740,6 +748,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 ()