Subject: linux-v4l2: Save device by id or path
From: Grzegorz Godlewski <[email-masked]>
Date: Mon, 16 May 2022 18:29:16 +0200

Bug: https://github.com/obsproject/obs-studio/issues/3003
Origin: https://github.com/obsproject/obs-studio/pull/6493
---
 plugins/linux-v4l2/v4l2-helpers.c |  12 ++
 plugins/linux-v4l2/v4l2-helpers.h |   9 +
 plugins/linux-v4l2/v4l2-input.c   | 382 ++++++++++++++++++++++++++++++--------
 3 files changed, 327 insertions(+), 76 deletions(-)

diff --git a/plugins/linux-v4l2/v4l2-helpers.c b/plugins/linux-v4l2/v4l2-helpers.c
index a40b132..cc8600f 100644
--- a/plugins/linux-v4l2/v4l2-helpers.c
+++ b/plugins/linux-v4l2/v4l2-helpers.c
@@ -318,3 +318,15 @@ int_fast32_t v4l2_set_dv_timing(int_fast32_t dev, int *timing)
 
 	return 0;
 }
+
+char *brealpath(const char *link_path)
+{
+	char *tmp_real_path = realpath(link_path, NULL);
+	if (NULL != tmp_real_path) {
+		char *real_path = bstrdup(tmp_real_path);
+		free(tmp_real_path);
+		return real_path;
+	} else {
+		return bstrdup(link_path);
+	}
+}
diff --git a/plugins/linux-v4l2/v4l2-helpers.h b/plugins/linux-v4l2/v4l2-helpers.h
index a53bf17..dcc5ca8 100644
--- a/plugins/linux-v4l2/v4l2-helpers.h
+++ b/plugins/linux-v4l2/v4l2-helpers.h
@@ -328,6 +328,15 @@ int_fast32_t v4l2_enum_dv_timing(int_fast32_t dev, struct v4l2_dv_timings *dvt,
  */
 int_fast32_t v4l2_set_dv_timing(int_fast32_t dev, int *timing);
 
+/**
+ * Resolves link_path into real_path or returns link_path if not a link.
+ *
+ * @param link_path path to link
+ *
+ * @return characters allocated using bstrdup. Has to freed with bfree
+ */
+char *brealpath(const char *link_path);
+
 #ifdef __cplusplus
 }
 #endif
diff --git a/plugins/linux-v4l2/v4l2-input.c b/plugins/linux-v4l2/v4l2-input.c
index a9ba8fe..92eb2be 100644
--- a/plugins/linux-v4l2/v4l2-input.c
+++ b/plugins/linux-v4l2/v4l2-input.c
@@ -70,7 +70,8 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 struct v4l2_data {
 	/* settings */
-	char *device_id;
+	char *device_id; // /dev/v4l/by-id/usb-046d_Logitech_BRIO_0D26FE7B-video-index0
+	char *real_path; // /dev/video0
 	int input;
 	int pixfmt;
 	int standard;
@@ -97,6 +98,16 @@ struct v4l2_data {
 	int timeout_frames;
 };
 
+enum v4l2_device_entry_type { DEVICE_BY_ID = 1, DEVICE_BY_PATH, DEVICE_OTHER };
+
+struct v4l2_device_entry {
+	enum v4l2_device_entry_type type;
+	struct dstr label;
+	struct dstr device_id;
+	struct dstr real_path;
+	struct v4l2_device_entry *next;
+};
+
 /* forward declarations */
 static void v4l2_init(struct v4l2_data *data);
 static void v4l2_terminate(struct v4l2_data *data);
@@ -351,96 +362,237 @@ static void v4l2_props_set_enabled(obs_properties_t *props,
 }
 
 /*
- * List available devices
+ * Get capabilities of device
  */
-static void v4l2_device_list(obs_property_t *prop, obs_data_t *settings)
+static bool get_device_capabilities(char *dev_name,
+				    struct v4l2_capability *video_cap)
+{
+	int fd;
+	uint32_t caps;
+	if ((fd = v4l2_open(dev_name, O_RDWR | O_NONBLOCK)) == -1) {
+		const char *errstr = strerror(errno);
+		blog(LOG_WARNING, "Unable to open %s: %s", dev_name, errstr);
+		return false;
+	}
+
+	if (v4l2_ioctl(fd, VIDIOC_QUERYCAP, video_cap) == -1) {
+		blog(LOG_WARNING, "Failed to query capabilities for %s",
+		     dev_name);
+		v4l2_close(fd);
+		return false;
+	}
+	v4l2_close(fd);
+
+#ifndef V4L2_CAP_DEVICE_CAPS
+	caps = video_cap->capabilities;
+#else
+	/* ... since Linux 3.3 */
+	caps = (video_cap->capabilities & V4L2_CAP_DEVICE_CAPS)
+		       ? video_cap->device_caps
+		       : video_cap->capabilities;
+#endif
+
+	if (!(caps & V4L2_CAP_VIDEO_CAPTURE)) {
+		blog(LOG_WARNING, "%s seems to not support video capture",
+		     dev_name);
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Build list of video devices available at path
+ */
+static struct v4l2_device_entry *
+v4l2_device_add_devices_from(struct v4l2_device_entry *first_device,
+			     const char *basedir,
+			     enum v4l2_device_entry_type type)
 {
-	DIR *dirp;
 	struct dirent *dp;
-	struct dstr device;
-	bool cur_device_found;
-	size_t cur_device_index;
-	const char *cur_device_name;
+	struct dstr full_path;
+	const char *scanned_dir;
+	struct dirent **namelist;
+	int no;
 
 #ifdef __FreeBSD__
-	dirp = opendir("/dev");
+	scanned_dir = basedir;
 #else
-	dirp = opendir("/sys/class/video4linux");
+	if (0 == strcmp("/dev/", basedir)) {
+		scanned_dir = "/sys/class/video4linux";
+	} else {
+		scanned_dir = basedir;
+	}
 #endif
-	if (!dirp)
-		return;
-
-	cur_device_found = false;
-	cur_device_name = obs_data_get_string(settings, "device_id");
 
-	obs_property_list_clear(prop);
+	dstr_init(&full_path);
 
-	dstr_init_copy(&device, "/dev/");
+	if ((no = scandir(scanned_dir, &namelist, 0, alphasort)) > 0) {
+		for (int i = 0; i < no; i++) {
+			dp = namelist[i];
 
-	while ((dp = readdir(dirp)) != NULL) {
-		int fd;
-		uint32_t caps;
-		struct v4l2_capability video_cap;
+			dstr_copy(&full_path, basedir);
+			dstr_cat(&full_path, dp->d_name);
 
+			char *real_path = brealpath(full_path.array);
 #ifdef __FreeBSD__
-		if (strstr(dp->d_name, "video") == NULL)
-			continue;
+			if (strstr(dp->d_name, "video") == NULL)
+				goto next_entry;
 #endif
 
-		if (dp->d_type == DT_DIR)
-			continue;
+			if (dp->d_type == DT_DIR)
+				goto next_entry;
 
-		dstr_resize(&device, 5);
-		dstr_cat(&device, dp->d_name);
+			struct v4l2_capability video_cap;
+			if (!get_device_capabilities(full_path.array,
+						     &video_cap)) {
+				goto next_entry;
+			}
 
-		if ((fd = v4l2_open(device.array, O_RDWR | O_NONBLOCK)) == -1) {
-			blog(LOG_INFO, "Unable to open %s", device.array);
-			continue;
+			if (type == DEVICE_OTHER) {
+				bool device_already_added = false;
+
+				struct v4l2_device_entry *current_device =
+					first_device;
+				while (current_device->next != NULL) {
+					if (strncmp(real_path,
+						    current_device->real_path
+							    .array,
+						    PATH_MAX) == 0) {
+						device_already_added = true;
+						break;
+					}
+					current_device = current_device->next;
+				}
+
+				if (device_already_added) {
+					goto next_entry;
+				}
+			}
+
+			char label[PATH_MAX];
+			if (0 == strcmp("/dev/v4l/by-path/", basedir)) {
+				snprintf(label, PATH_MAX, "Bus: %s (%s)",
+					 video_cap.bus_info, dp->d_name);
+			} else {
+				snprintf(label, PATH_MAX, "%s (%s)",
+					 video_cap.card, dp->d_name);
+			}
+
+			struct v4l2_device_entry *last_device;
+			if (first_device == NULL) {
+				first_device = bzalloc(
+					sizeof(struct v4l2_device_entry));
+				last_device = first_device;
+			} else {
+				last_device = first_device;
+				while (last_device->next != NULL) {
+					last_device = last_device->next;
+				}
+				last_device->next = bzalloc(
+					sizeof(struct v4l2_device_entry));
+				last_device = last_device->next;
+			}
+
+			last_device->next = NULL;
+			last_device->type = type;
+			dstr_init_copy(&last_device->label, label);
+			dstr_init_copy(&last_device->device_id,
+				       full_path.array);
+			dstr_init_copy(&last_device->real_path, real_path);
+
+			blog(LOG_INFO, "Found device '%s' at %s",
+			     video_cap.card, full_path.array);
+		next_entry:
+			free(dp);
+			bfree(real_path);
 		}
+	}
 
-		if (v4l2_ioctl(fd, VIDIOC_QUERYCAP, &video_cap) == -1) {
-			blog(LOG_INFO, "Failed to query capabilities for %s",
-			     device.array);
-			v4l2_close(fd);
-			continue;
+	free(namelist);
+	dstr_free(&full_path);
+
+	return first_device;
+}
+
+/*
+ * Build UI for video devices list
+ */
+static void v4l2_build_device_list_ui(obs_property_t *prop,
+				      struct v4l2_device_entry *current_device)
+{
+	enum v4l2_device_entry_type last_type = -1;
+	bool list_empty = true;
+
+	while (current_device != NULL) {
+		if (last_type != current_device->type && !list_empty) {
+			if (current_device->type == DEVICE_BY_PATH) {
+				obs_property_list_item_disable(
+					prop,
+					obs_property_list_add_string(
+						prop,
+						"Select device by connection:",
+						"by_path"),
+					true);
+			} else if (current_device->type == DEVICE_OTHER) {
+				obs_property_list_item_disable(
+					prop,
+					obs_property_list_add_string(
+						prop,
+						"Other devices:", "other"),
+					true);
+			}
 		}
+		obs_property_list_add_string(prop, current_device->label.array,
+					     current_device->device_id.array);
+
+		dstr_free(&current_device->label);
+		dstr_free(&current_device->device_id);
+		dstr_free(&current_device->real_path);
+		struct v4l2_device_entry *next_device = current_device->next;
+		last_type = current_device->type;
+		bfree(current_device);
+		current_device = next_device;
+		list_empty = false;
+	}
+}
 
-#ifndef V4L2_CAP_DEVICE_CAPS
-		caps = video_cap.capabilities;
+/*
+ * List available devices
+ */
+static void v4l2_device_list(obs_property_t *prop, obs_data_t *settings)
+{
+	bool cur_device_found;
+	size_t cur_device_index;
+	const char *cur_device_name;
+
+	obs_property_list_clear(prop);
+
+	struct v4l2_device_entry *first_device = NULL;
+#ifdef __FreeBSD__
+	first_device = v4l2_device_add_devices_from(first_device, "/dev/",
+						    DEVICE_OTHER);
 #else
-		/* ... since Linux 3.3 */
-		caps = (video_cap.capabilities & V4L2_CAP_DEVICE_CAPS)
-			       ? video_cap.device_caps
-			       : video_cap.capabilities;
+	first_device = v4l2_device_add_devices_from(
+		first_device, "/dev/v4l/by-id/", DEVICE_BY_ID);
+	first_device = v4l2_device_add_devices_from(
+		first_device, "/dev/v4l/by-path/", DEVICE_BY_PATH);
+	first_device = v4l2_device_add_devices_from(first_device, "/dev/",
+						    DEVICE_OTHER);
 #endif
+	v4l2_build_device_list_ui(prop, first_device);
 
-		if (!(caps & V4L2_CAP_VIDEO_CAPTURE)) {
-			blog(LOG_INFO, "%s seems to not support video capture",
-			     device.array);
-			v4l2_close(fd);
-			continue;
-		}
+	cur_device_found = false;
+	cur_device_name = obs_data_get_string(settings, "device_id");
 
-		/* make sure device names are unique */
-		char unique_device_name[68];
-		int ret = snprintf(unique_device_name,
-				   sizeof(unique_device_name), "%s (%s)",
-				   video_cap.card, video_cap.bus_info);
-		if (ret >= (int)sizeof(unique_device_name))
-			blog(LOG_DEBUG,
-			     "linux-v4l2: A format truncation may have occurred."
-			     " This can be ignored since it is quite improbable.");
-
-		obs_property_list_add_string(prop, unique_device_name,
-					     device.array);
-		blog(LOG_INFO, "Found device '%s' at %s", video_cap.card,
-		     device.array);
-
-		/* check if this is the currently used device */
-		if (cur_device_name && !strcmp(cur_device_name, device.array))
+	size_t listidx = 0;
+	const char *item_name;
+	while (NULL !=
+	       (item_name = obs_property_list_item_string(prop, listidx++))) {
+		if (0 == strcmp(item_name, cur_device_name)) {
 			cur_device_found = true;
-
-		v4l2_close(fd);
+			break;
+		}
 	}
 
 	/* add currently selected device if not present, but disable it ... */
@@ -449,9 +601,6 @@ static void v4l2_device_list(obs_property_t *prop, obs_data_t *settings)
 			prop, cur_device_name, cur_device_name);
 		obs_property_list_item_disable(prop, cur_device_index, true);
 	}
-
-	closedir(dirp);
-	dstr_free(&device);
 }
 
 /*
@@ -795,6 +944,59 @@ static bool resolution_selected(obs_properties_t *props, obs_property_t *p,
 }
 
 #if HAVE_UDEV
+/**
+ * Converts /dev/video0 to /dev/v4l/by-id/usb-046d_Logitech_BRIO_0D26FE7B-video-index0
+ *
+ * If not found returns NULL
+ * Use bfree to deallocate
+ */
+static char *find_device_id_by_real_path(const char *device_added_real_path,
+					 enum v4l2_device_entry_type type)
+{
+	DIR *dirp;
+	struct dirent *dp;
+	const char *scanned_dir = "/dev/";
+	struct dstr full_path;
+	char *ret_val = NULL;
+
+#ifndef __FreeBSD__
+	switch (type) {
+	case DEVICE_BY_ID:
+		scanned_dir = "/dev/v4l/by-id/";
+		break;
+	case DEVICE_BY_PATH:
+		scanned_dir = "/dev/v4l/by-path/";
+		break;
+	}
+#endif
+
+	dirp = opendir(scanned_dir);
+	if (!dirp)
+		return ret_val;
+
+	dstr_init(&full_path);
+
+	while ((dp = readdir(dirp)) != NULL) {
+		dstr_copy(&full_path, scanned_dir);
+		dstr_cat(&full_path, dp->d_name);
+
+		char *real_path = brealpath(full_path.array);
+
+		if (0 == strcmp(real_path, device_added_real_path)) {
+			ret_val = bstrdup(full_path.array);
+			free(real_path);
+			break;
+		}
+
+		bfree(real_path);
+	}
+
+	dstr_free(&full_path);
+	closedir(dirp);
+
+	return ret_val;
+}
+
 /**
  * Device added callback
  *
@@ -807,15 +1009,34 @@ static void device_added(void *vptr, calldata_t *calldata)
 
 	obs_source_update_properties(data->source);
 
-	const char *dev;
-	calldata_get_string(calldata, "device", &dev);
+	const char *device_added_real_path;
+	calldata_get_string(calldata, "device", &device_added_real_path);
 
-	if (strcmp(data->device_id, dev))
-		return;
+	enum v4l2_device_entry_type type = DEVICE_OTHER;
+	if (strstr(data->device_id, "/by-id/")) {
+		type = DEVICE_BY_ID;
+	}
+	if (strstr(data->device_id, "/by-path/")) {
+		type = DEVICE_BY_PATH;
+	}
+
+	char *device_added_device_id =
+		find_device_id_by_real_path(device_added_real_path, type);
 
-	blog(LOG_INFO, "Device %s reconnected", dev);
+	if (NULL == device_added_device_id) {
+		goto fail;
+	}
+
+	if (strcmp(device_added_device_id, data->device_id))
+		goto fail;
+
+	blog(LOG_INFO, "Device %s reconnected, is opened as %s",
+	     device_added_device_id, data->device_id);
 
 	v4l2_init(data);
+fail:
+	if (NULL != device_added_device_id)
+		bfree(device_added_device_id);
 }
 /**
  * Device removed callback
@@ -831,10 +1052,11 @@ static void device_removed(void *vptr, calldata_t *calldata)
 	const char *dev;
 	calldata_get_string(calldata, "device", &dev);
 
-	if (strcmp(data->device_id, dev))
+	if (strcmp(data->real_path, dev))
 		return;
 
-	blog(LOG_INFO, "Device %s disconnected", dev);
+	blog(LOG_INFO, "Device %s disconnected, was opened as %s", dev,
+	     data->device_id);
 
 	v4l2_terminate(data);
 }
@@ -952,6 +1174,9 @@ static void v4l2_destroy(void *vptr)
 	if (data->device_id)
 		bfree(data->device_id);
 
+	if (data->real_path)
+		bfree(data->real_path);
+
 #if HAVE_UDEV
 	signal_handler_t *sh = v4l2_get_udev_signalhandler();
 
@@ -1155,6 +1380,9 @@ static void v4l2_update(void *vptr, obs_data_t *settings)
 	if (data->device_id)
 		bfree(data->device_id);
 
+	if (data->real_path)
+		bfree(data->real_path);
+
 	data->device_id = bstrdup(obs_data_get_string(settings, "device_id"));
 	data->input = obs_data_get_int(settings, "input");
 	data->pixfmt = obs_data_get_int(settings, "pixelformat");
@@ -1166,6 +1394,8 @@ static void v4l2_update(void *vptr, obs_data_t *settings)
 	data->auto_reset = obs_data_get_bool(settings, "auto_reset");
 	data->timeout_frames = obs_data_get_int(settings, "timeout_frames");
 
+	data->real_path = brealpath(data->device_id);
+
 	v4l2_update_source_flags(data, settings);
 
 	if (needs_restart)