Browse Source

UI: Rewrite scene collection system to enable user-provided storage

This change enables loading scene collections from locations different
than OBS' own configuration directory.

It also rewrites profile management in the app to work off an in-memory
collection of profiles found on disk and does not require iterating
over directory contents for most profile interactions by the app.
PatTheMav 1 year ago
parent
commit
3e0592dc20
6 changed files with 663 additions and 427 deletions
  1. 8 13
      UI/api-interface.cpp
  2. 33 42
      UI/obs-app.cpp
  3. 511 307
      UI/window-basic-main-scene-collections.cpp
  4. 28 39
      UI/window-basic-main.cpp
  5. 50 12
      UI/window-basic-main.hpp
  6. 33 14
      UI/window-importer.cpp

+ 8 - 13
UI/api-interface.cpp

@@ -16,8 +16,6 @@ template<typename T> static T GetOBSRef(QListWidgetItem *item)
 	return item->data(static_cast<int>(QtDataRole::OBSRef)).value<T>();
 }
 
-void EnumSceneCollections(function<bool(const char *, const char *)> &&cb);
-
 extern volatile bool streaming_active;
 extern volatile bool recording_active;
 extern volatile bool recording_paused;
@@ -168,19 +166,17 @@ struct OBSStudioAPI : obs_frontend_callbacks {
 	void obs_frontend_get_scene_collections(
 		std::vector<std::string> &strings) override
 	{
-		auto addCollection = [&](const char *name, const char *) {
-			strings.emplace_back(name);
-			return true;
-		};
-
-		EnumSceneCollections(addCollection);
+		for (auto &[collectionName, collection] :
+		     main->GetSceneCollectionCache()) {
+			strings.emplace_back(collectionName);
+		}
 	}
 
 	char *obs_frontend_get_current_scene_collection(void) override
 	{
-		const char *cur_name = config_get_string(
-			App()->GlobalConfig(), "Basic", "SceneCollection");
-		return bstrdup(cur_name);
+		const OBSSceneCollection &currentCollection =
+			main->GetCurrentSceneCollection();
+		return bstrdup(currentCollection.name.c_str());
 	}
 
 	void obs_frontend_set_current_scene_collection(
@@ -206,10 +202,9 @@ struct OBSStudioAPI : obs_frontend_callbacks {
 	bool obs_frontend_add_scene_collection(const char *name) override
 	{
 		bool success = false;
-		QMetaObject::invokeMethod(main, "AddSceneCollection",
+		QMetaObject::invokeMethod(main, "NewSceneCollection",
 					  WaitConnection(),
 					  Q_RETURN_ARG(bool, success),
-					  Q_ARG(bool, true),
 					  Q_ARG(QString, QT_UTF8(name)));
 		return success;
 	}

+ 33 - 42
UI/obs-app.cpp

@@ -1220,27 +1220,48 @@ static void move_basic_to_profiles(void)
 static void move_basic_to_scene_collections(void)
 {
 	char path[512];
-	char new_path[512];
 
-	if (GetConfigPath(path, 512, "obs-studio/basic") <= 0)
-		return;
-	if (!os_file_exists(path))
+	if (GetAppConfigPath(path, 512, "obs-studio/basic") <= 0) {
 		return;
+	}
+
+	const std::filesystem::path basicPath = std::filesystem::u8path(path);
 
-	if (GetConfigPath(new_path, 512, "obs-studio/basic/scenes") <= 0)
+	if (!std::filesystem::exists(basicPath)) {
 		return;
-	if (os_file_exists(new_path))
+	}
+
+	const std::filesystem::path sceneCollectionPath =
+		App()->userScenesLocation /
+		std::filesystem::u8path("obs-studio/basic/scenes");
+
+	if (std::filesystem::exists(sceneCollectionPath)) {
 		return;
+	}
 
-	if (os_mkdir(new_path) == MKDIR_ERROR)
+	try {
+		std::filesystem::create_directories(sceneCollectionPath);
+	} catch (const std::filesystem::filesystem_error &error) {
+		blog(LOG_ERROR,
+		     "Failed to create scene collection directory for migration from basic scene collection\n%s",
+		     error.what());
 		return;
+	}
 
-	strcat(path, "/scenes.json");
-	strcat(new_path, "/");
-	strcat(new_path, Str("Untitled"));
-	strcat(new_path, ".json");
+	const std::filesystem::path sourceFile =
+		basicPath / std::filesystem::u8path("scenes.json");
+	const std::filesystem::path destinationFile =
+		(sceneCollectionPath / std::filesystem::u8path(Str("Untitled")))
+			.replace_extension(".json");
 
-	os_rename(path, new_path);
+	try {
+		std::filesystem::rename(sourceFile, destinationFile);
+	} catch (const std::filesystem::filesystem_error &error) {
+		blog(LOG_ERROR,
+		     "Failed to rename basic scene collection file:\n%s",
+		     error.what());
+		return;
+	}
 }
 
 void OBSApp::AppInit()
@@ -2524,36 +2545,6 @@ bool GetClosestUnusedFileName(std::string &path, const char *extension)
 	return true;
 }
 
-bool GetUnusedSceneCollectionFile(std::string &name, std::string &file)
-{
-	char path[512];
-	int ret;
-
-	if (!GetFileSafeName(name.c_str(), file)) {
-		blog(LOG_WARNING, "Failed to create safe file name for '%s'",
-		     name.c_str());
-		return false;
-	}
-
-	ret = GetConfigPath(path, sizeof(path), "obs-studio/basic/scenes/");
-	if (ret <= 0) {
-		blog(LOG_WARNING, "Failed to get scene collection config path");
-		return false;
-	}
-
-	file.insert(0, path);
-
-	if (!GetClosestUnusedFileName(file, "json")) {
-		blog(LOG_WARNING, "Failed to get closest file name for %s",
-		     file.c_str());
-		return false;
-	}
-
-	file.erase(file.size() - 5, 5);
-	file.erase(0, strlen(path));
-	return true;
-}
-
 bool WindowPositionValid(QRect rect)
 {
 	for (QScreen *screen : QGuiApplication::screens()) {

+ 511 - 307
UI/window-basic-main-scene-collections.cpp

@@ -15,6 +15,9 @@
     along with this program.  If not, see <http://www.gnu.org/licenses/>.
 ******************************************************************************/
 
+#include <filesystem>
+#include <string>
+
 #include <obs.hpp>
 #include <util/util.hpp>
 #include <QMessageBox>
@@ -27,209 +30,319 @@
 #include "window-importer.hpp"
 #include "window-namedialog.hpp"
 
-using namespace std;
+// MARK: Constant Expressions
+
+constexpr std::string_view OBSSceneCollectionPath = "/obs-studio/basic/scenes/";
 
-void EnumSceneCollections(std::function<bool(const char *, const char *)> &&cb)
+// MARK: - Main Scene Collection Management Functions
+
+void OBSBasic::SetupNewSceneCollection(const std::string &collectionName)
 {
-	char path[512];
-	os_glob_t *glob;
-
-	int ret = GetConfigPath(path, sizeof(path),
-				"obs-studio/basic/scenes/*.json");
-	if (ret <= 0) {
-		blog(LOG_WARNING, "Failed to get config path for scene "
-				  "collections");
-		return;
-	}
+	const OBSSceneCollection &newCollection =
+		CreateSceneCollection(collectionName);
 
-	if (os_glob(path, 0, &glob) != 0) {
-		blog(LOG_WARNING, "Failed to glob scene collections");
-		return;
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
+
+	ActivateSceneCollection(newCollection);
+
+	blog(LOG_INFO, "Created scene collection '%s' (clean, %s)",
+	     newCollection.name.c_str(), newCollection.fileName.c_str());
+	blog(LOG_INFO, "------------------------------------------------");
+}
+
+void OBSBasic::SetupDuplicateSceneCollection(const std::string &collectionName)
+{
+	const OBSSceneCollection &newCollection =
+		CreateSceneCollection(collectionName);
+	const OBSSceneCollection &currentCollection =
+		GetCurrentSceneCollection();
+
+	SaveProjectNow();
+
+	const auto copyOptions =
+		std::filesystem::copy_options::overwrite_existing;
+
+	try {
+		std::filesystem::copy(currentCollection.collectionFile,
+				      newCollection.collectionFile,
+				      copyOptions);
+	} catch (const std::filesystem::filesystem_error &error) {
+		blog(LOG_DEBUG, "%s", error.what());
+		throw std::logic_error(
+			"Failed to copy file for cloned scene collection: " +
+			newCollection.name);
 	}
 
-	for (size_t i = 0; i < glob->gl_pathc; i++) {
-		const char *filePath = glob->gl_pathv[i].path;
+	OBSDataAutoRelease collection = obs_data_create_from_json_file(
+		newCollection.collectionFile.u8string().c_str());
 
-		if (glob->gl_pathv[i].directory)
-			continue;
+	obs_data_set_string(collection, "name", newCollection.name.c_str());
 
-		OBSDataAutoRelease data =
-			obs_data_create_from_json_file_safe(filePath, "bak");
-		std::string name = obs_data_get_string(data, "name");
+	OBSDataArrayAutoRelease sources =
+		obs_data_get_array(collection, "sources");
 
-		/* if no name found, use the file name as the name
-		 * (this only happens when switching to the new version) */
-		if (name.empty()) {
-			name = strrchr(filePath, '/') + 1;
-			name.resize(name.size() - 5);
-		}
+	if (sources) {
+		obs_data_erase(collection, "sources");
+
+		obs_data_array_enum(
+			sources,
+			[](obs_data_t *data, void *) -> void {
+				const char *uuid = os_generate_uuid();
 
-		if (!cb(name.c_str(), filePath))
-			break;
+				obs_data_set_string(data, "uuid", uuid);
+
+				bfree((void *)uuid);
+			},
+			nullptr);
+
+		obs_data_set_array(collection, "sources", sources);
 	}
 
-	os_globfree(glob);
+	obs_data_save_json_safe(collection,
+				newCollection.collectionFile.u8string().c_str(),
+				"tmp", nullptr);
+
+	ActivateSceneCollection(newCollection);
+
+	blog(LOG_INFO, "Created scene collection '%s' (duplicate, %s)",
+	     newCollection.name.c_str(), newCollection.fileName.c_str());
+	blog(LOG_INFO, "------------------------------------------------");
 }
 
-bool SceneCollectionExists(const char *findName)
+void OBSBasic::SetupRenameSceneCollection(const std::string &collectionName)
 {
-	bool found = false;
-	auto func = [&](const char *name, const char *) {
-		if (strcmp(name, findName) == 0) {
-			found = true;
-			return false;
-		}
+	const OBSSceneCollection &newCollection =
+		CreateSceneCollection(collectionName);
+	const OBSSceneCollection currentCollection =
+		GetCurrentSceneCollection();
 
-		return true;
-	};
+	SaveProjectNow();
+
+	const auto copyOptions =
+		std::filesystem::copy_options::overwrite_existing;
+
+	try {
+		std::filesystem::copy(currentCollection.collectionFile,
+				      newCollection.collectionFile,
+				      copyOptions);
+	} catch (const std::filesystem::filesystem_error &error) {
+		blog(LOG_DEBUG, "%s", error.what());
+		throw std::logic_error(
+			"Failed to copy file for scene collection: " +
+			currentCollection.name);
+	}
+
+	collections.erase(currentCollection.name);
+
+	OBSDataAutoRelease collection = obs_data_create_from_json_file(
+		newCollection.collectionFile.u8string().c_str());
+
+	obs_data_set_string(collection, "name", newCollection.name.c_str());
 
-	EnumSceneCollections(func);
-	return found;
+	obs_data_save_json_safe(collection,
+				newCollection.collectionFile.u8string().c_str(),
+				"tmp", nullptr);
+
+	ActivateSceneCollection(newCollection);
+	RemoveSceneCollection(currentCollection);
+
+	blog(LOG_INFO, "Renamed scene collection '%s' to '%s' (%s)",
+	     currentCollection.name.c_str(), newCollection.name.c_str(),
+	     newCollection.fileName.c_str());
+	blog(LOG_INFO, "------------------------------------------------");
+
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_RENAMED);
 }
 
-static bool GetSceneCollectionName(QWidget *parent, std::string &name,
-				   std::string &file,
-				   const char *oldName = nullptr)
+// MARK: - Scene Collection File Management Functions
+
+const OBSSceneCollection &
+OBSBasic::CreateSceneCollection(const std::string &collectionName)
 {
-	bool rename = oldName != nullptr;
-	const char *title;
-	const char *text;
+	if (const auto &foundCollection =
+		    GetSceneCollectionByName(collectionName)) {
+		throw std::invalid_argument(
+			"Scene collection already exists: " + collectionName);
+	}
 
-	if (rename) {
-		title = Str("Basic.Main.RenameSceneCollection.Title");
-		text = Str("Basic.Main.AddSceneCollection.Text");
-	} else {
-		title = Str("Basic.Main.AddSceneCollection.Title");
-		text = Str("Basic.Main.AddSceneCollection.Text");
+	std::string fileName;
+	if (!GetFileSafeName(collectionName.c_str(), fileName)) {
+		throw std::invalid_argument(
+			"Failed to create safe directory for new scene collection: " +
+			collectionName);
 	}
 
-	for (;;) {
-		bool success = NameDialog::AskForName(parent, title, text, name,
-						      QT_UTF8(oldName));
-		if (!success) {
-			return false;
-		}
-		if (name.empty()) {
-			OBSMessageBox::warning(parent,
-					       QTStr("NoNameEntered.Title"),
-					       QTStr("NoNameEntered.Text"));
-			continue;
-		}
-		if (SceneCollectionExists(name.c_str())) {
-			OBSMessageBox::warning(parent,
-					       QTStr("NameExists.Title"),
-					       QTStr("NameExists.Text"));
-			continue;
-		}
-		break;
+	std::string collectionFile;
+	collectionFile.reserve(App()->userScenesLocation.u8string().size() +
+			       OBSSceneCollectionPath.size() + fileName.size());
+	collectionFile.append(App()->userScenesLocation.u8string())
+		.append(OBSSceneCollectionPath)
+		.append(fileName);
+
+	if (!GetClosestUnusedFileName(collectionFile, "json")) {
+		throw std::invalid_argument(
+			"Failed to get closest file name for new scene collection: " +
+			fileName);
 	}
 
-	if (!GetUnusedSceneCollectionFile(name, file)) {
-		return false;
+	const std::filesystem::path collectionFilePath =
+		std::filesystem::u8path(collectionFile);
+
+	auto [iterator, success] = collections.try_emplace(
+		collectionName,
+		OBSSceneCollection{collectionName,
+				   collectionFilePath.filename().u8string(),
+				   collectionFilePath});
+
+	return iterator->second;
+}
+
+void OBSBasic::RemoveSceneCollection(OBSSceneCollection collection)
+{
+	std::filesystem::path collectionBackupFile{collection.collectionFile};
+	collectionBackupFile.replace_extension("json.bak");
+
+	try {
+		std::filesystem::remove(collection.collectionFile);
+		std::filesystem::remove(collectionBackupFile);
+	} catch (const std::filesystem::filesystem_error &error) {
+		blog(LOG_DEBUG, "%s", error.what());
+		throw std::logic_error(
+			"Failed to remove scene collection file: " +
+			collection.fileName);
 	}
 
-	return true;
+	blog(LOG_INFO, "Removed scene collection '%s' (%s)",
+	     collection.name.c_str(), collection.fileName.c_str());
+	blog(LOG_INFO, "------------------------------------------------");
+}
+
+// MARK: - Scene Collection UI Handling Functions
+
+bool OBSBasic::CreateNewSceneCollection(const QString &name)
+{
+	try {
+		SetupNewSceneCollection(name.toStdString());
+		return true;
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+		return false;
+	} catch (const std::logic_error &error) {
+		blog(LOG_ERROR, "%s", error.what());
+		return false;
+	}
 }
 
-bool OBSBasic::AddSceneCollection(bool create_new, const QString &qname)
+bool OBSBasic::CreateDuplicateSceneCollection(const QString &name)
 {
-	std::string name;
-	std::string file;
+	try {
+		SetupDuplicateSceneCollection(name.toStdString());
+		return true;
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+		return false;
+	} catch (const std::logic_error &error) {
+		blog(LOG_ERROR, "%s", error.what());
+		return false;
+	}
+}
 
-	if (qname.isEmpty()) {
-		if (!GetSceneCollectionName(this, name, file))
-			return false;
-	} else {
-		name = QT_TO_UTF8(qname);
-		if (SceneCollectionExists(name.c_str()))
-			return false;
+void OBSBasic::DeleteSceneCollection(const QString &name)
+{
+	const std::string_view currentCollectionName{config_get_string(
+		App()->GetUserConfig(), "Basic", "SceneCollection")};
 
-		if (!GetUnusedSceneCollectionFile(name, file)) {
-			return false;
-		}
+	if (currentCollectionName == name.toStdString()) {
+		on_actionRemoveSceneCollection_triggered();
+		return;
 	}
 
-	auto new_collection = [this, create_new](const std::string &file,
-						 const std::string &name) {
-		SaveProjectNow();
+	OBSSceneCollection currentCollection = GetCurrentSceneCollection();
 
-		config_set_string(App()->GlobalConfig(), "Basic",
-				  "SceneCollection", name.c_str());
-		config_set_string(App()->GlobalConfig(), "Basic",
-				  "SceneCollectionFile", file.c_str());
+	RemoveSceneCollection(currentCollection);
 
-		if (create_new) {
-			CreateDefaultScene(false);
-		} else {
-			obs_reset_source_uuids();
-		}
+	collections.erase(name.toStdString());
 
-		SaveProjectNow();
-		RefreshSceneCollections();
-	};
+	RefreshSceneCollections();
 
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_LIST_CHANGED);
+}
 
-	new_collection(file, name);
+void OBSBasic::ChangeSceneCollection()
+{
+	QAction *action = reinterpret_cast<QAction *>(sender());
 
-	blog(LOG_INFO, "Added scene collection '%s' (%s, %s.json)",
-	     name.c_str(), create_new ? "clean" : "duplicate", file.c_str());
-	blog(LOG_INFO, "------------------------------------------------");
+	if (!action) {
+		return;
+	}
 
-	UpdateTitleBar();
+	const std::string_view currentCollectionName{config_get_string(
+		App()->GetUserConfig(), "Basic", "SceneCollection")};
+	const std::string selectedCollectionName{action->text().toStdString()};
 
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_LIST_CHANGED);
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED);
+	if (currentCollectionName == selectedCollectionName) {
+		action->setChecked(true);
+		return;
+	}
+
+	const std::optional<OBSSceneCollection> foundCollection =
+		GetSceneCollectionByName(selectedCollectionName);
+
+	if (!foundCollection) {
+		const std::string errorMessage{
+			"Selected scene collection not found: "};
+
+		throw std::invalid_argument(errorMessage +
+					    currentCollectionName.data());
+	}
+
+	const OBSSceneCollection &selectedCollection = foundCollection.value();
+
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
+
+	ActivateSceneCollection(selectedCollection);
 
-	return true;
+	blog(LOG_INFO, "Switched to scene collection '%s' (%s)",
+	     selectedCollection.name.c_str(),
+	     selectedCollection.fileName.c_str());
+	blog(LOG_INFO, "------------------------------------------------");
 }
 
-void OBSBasic::RefreshSceneCollections()
+void OBSBasic::RefreshSceneCollections(bool refreshCache)
 {
+	std::string_view currentCollectionName{config_get_string(
+		App()->GetUserConfig(), "Basic", "SceneCollection")};
+
 	QList<QAction *> menuActions = ui->sceneCollectionMenu->actions();
-	int count = 0;
 
-	for (int i = 0; i < menuActions.count(); i++) {
-		QVariant v = menuActions[i]->property("file_name");
-		if (v.typeName() != nullptr)
-			delete menuActions[i];
+	for (auto &action : menuActions) {
+		QVariant variant = action->property("file_name");
+		if (variant.typeName() != nullptr) {
+			delete action;
+		}
 	}
 
-	const char *cur_name = config_get_string(App()->GlobalConfig(), "Basic",
-						 "SceneCollection");
-
-	auto addCollection = [&](const char *name, const char *path) {
-		std::string file = strrchr(path, '/') + 1;
-		file.erase(file.size() - 5, 5);
+	if (refreshCache) {
+		RefreshSceneCollectionCache();
+	}
 
-		QAction *action = new QAction(QT_UTF8(name), this);
-		action->setProperty("file_name", QT_UTF8(path));
+	size_t numAddedCollections = 0;
+	for (auto &[collectionName, collection] : collections) {
+		QAction *action = new QAction(
+			QString().fromStdString(collectionName), this);
+		action->setProperty("file_name", QString().fromStdString(
+							 collection.fileName));
 		connect(action, &QAction::triggered, this,
 			&OBSBasic::ChangeSceneCollection);
 		action->setCheckable(true);
-
-		action->setChecked(strcmp(name, cur_name) == 0);
+		action->setChecked(collectionName == currentCollectionName);
 
 		ui->sceneCollectionMenu->addAction(action);
-		count++;
-		return true;
-	};
 
-	EnumSceneCollections(addCollection);
-
-	/* force saving of first scene collection on first run, otherwise
-	 * no scene collections will show up */
-	if (!count) {
-		long prevDisableVal = disableSaving;
-
-		disableSaving = 0;
-		SaveProjectNow();
-		disableSaving = prevDisableVal;
-
-		EnumSceneCollections(addCollection);
+		numAddedCollections += 1;
 	}
 
-	ui->actionRemoveSceneCollection->setEnabled(count > 1);
+	ui->actionRemoveSceneCollection->setEnabled(numAddedCollections > 1);
 
 	OBSBasic *main = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
 
@@ -238,125 +351,243 @@ void OBSBasic::RefreshSceneCollections()
 	main->ui->actionPasteDup->setEnabled(false);
 }
 
-void OBSBasic::on_actionNewSceneCollection_triggered()
+// MARK: - Scene Collection Cache Functions
+
+void OBSBasic::RefreshSceneCollectionCache()
+{
+	OBSSceneCollectionCache foundCollections{};
+
+	const std::filesystem::path collectionsPath =
+		App()->userScenesLocation /
+		std::filesystem::u8path(OBSSceneCollectionPath.substr(1));
+
+	if (!std::filesystem::exists(collectionsPath)) {
+		blog(LOG_WARNING,
+		     "Failed to get scene collections config path");
+		return;
+	}
+
+	for (const auto &entry :
+	     std::filesystem::directory_iterator(collectionsPath)) {
+		if (entry.is_directory()) {
+			continue;
+		}
+
+		if (entry.path().extension().u8string() != ".json") {
+			continue;
+		}
+
+		OBSDataAutoRelease collectionData =
+			obs_data_create_from_json_file_safe(
+				entry.path().u8string().c_str(), "bak");
+
+		std::string candidateName;
+		const char *collectionName =
+			obs_data_get_string(collectionData, "name");
+
+		if (!collectionName) {
+			candidateName = entry.path().filename().u8string();
+		} else {
+			candidateName = collectionName;
+		}
+
+		foundCollections.try_emplace(
+			candidateName,
+			OBSSceneCollection{candidateName,
+					   entry.path().filename().u8string(),
+					   entry.path()});
+	}
+
+	collections.swap(foundCollections);
+}
+
+const OBSSceneCollection &OBSBasic::GetCurrentSceneCollection() const
 {
-	AddSceneCollection(true);
+	std::string currentCollectionName{config_get_string(
+		App()->GetUserConfig(), "Basic", "SceneCollection")};
+
+	if (currentCollectionName.empty()) {
+		throw std::invalid_argument(
+			"No valid scene collection name in configuration Basic->SceneCollection");
+	}
+
+	const auto &foundCollection = collections.find(currentCollectionName);
+
+	if (foundCollection != collections.end()) {
+		return foundCollection->second;
+	} else {
+		throw std::invalid_argument(
+			"Scene collection not found in collection list: " +
+			currentCollectionName);
+	}
 }
 
-void OBSBasic::on_actionDupSceneCollection_triggered()
+std::optional<OBSSceneCollection>
+OBSBasic::GetSceneCollectionByName(const std::string &collectionName) const
 {
-	AddSceneCollection(false);
+	auto foundCollection = collections.find(collectionName);
+
+	if (foundCollection == collections.end()) {
+		return {};
+	} else {
+		return foundCollection->second;
+	}
 }
 
-void OBSBasic::on_actionRenameSceneCollection_triggered()
+std::optional<OBSSceneCollection>
+OBSBasic::GetSceneCollectionByFileName(const std::string &fileName) const
 {
-	std::string name;
-	std::string file;
-	std::string oname;
-
-	std::string oldFile = config_get_string(App()->GlobalConfig(), "Basic",
-						"SceneCollectionFile");
-	const char *oldName = config_get_string(App()->GlobalConfig(), "Basic",
-						"SceneCollection");
-	oname = std::string(oldName);
-
-	bool success = GetSceneCollectionName(this, name, file, oldName);
-	if (!success)
-		return;
+	for (auto &[iterator, collection] : collections) {
+		if (collection.fileName == fileName) {
+			return collection;
+		}
+	}
 
-	config_set_string(App()->GlobalConfig(), "Basic", "SceneCollection",
-			  name.c_str());
-	config_set_string(App()->GlobalConfig(), "Basic", "SceneCollectionFile",
-			  file.c_str());
-	SaveProjectNow();
+	return {};
+}
+
+// MARK: - Qt Slot Functions
 
-	char path[512];
-	int ret = GetConfigPath(path, 512, "obs-studio/basic/scenes/");
-	if (ret <= 0) {
-		blog(LOG_WARNING, "Failed to get scene collection config path");
+void OBSBasic::on_actionNewSceneCollection_triggered()
+{
+	const OBSPromptCallback sceneCollectionCallback =
+		[this](const OBSPromptResult &result) {
+			if (GetSceneCollectionByName(result.promptValue)) {
+				return false;
+			}
+
+			return true;
+		};
+
+	const OBSPromptRequest request{
+		Str("Basic.Main.AddSceneCollection.Title"),
+		Str("Basic.Main.AddSceneCollection.Text")};
+
+	OBSPromptResult result =
+		PromptForName(request, sceneCollectionCallback);
+
+	if (!result.success) {
 		return;
 	}
 
-	oldFile.insert(0, path);
-	oldFile += ".json";
-	os_unlink(oldFile.c_str());
-	oldFile += ".bak";
-	os_unlink(oldFile.c_str());
+	try {
+		SetupNewSceneCollection(result.promptValue);
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	} catch (const std::logic_error &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	}
+}
+
+void OBSBasic::on_actionDupSceneCollection_triggered()
+{
+	const OBSPromptCallback sceneCollectionCallback =
+		[this](const OBSPromptResult &result) {
+			if (GetSceneCollectionByName(result.promptValue)) {
+				return false;
+			}
 
-	blog(LOG_INFO, "------------------------------------------------");
-	blog(LOG_INFO, "Renamed scene collection to '%s' (%s.json)",
-	     name.c_str(), file.c_str());
-	blog(LOG_INFO, "------------------------------------------------");
+			return true;
+		};
 
-	UpdateTitleBar();
-	RefreshSceneCollections();
+	const OBSPromptRequest request{
+		Str("Basic.Main.AddSceneCollection.Title"),
+		Str("Basic.Main.AddSceneCollection.Text")};
 
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_RENAMED);
+	OBSPromptResult result =
+		PromptForName(request, sceneCollectionCallback);
+
+	if (!result.success) {
+		return;
+	}
+
+	try {
+		SetupDuplicateSceneCollection(result.promptValue);
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	} catch (const std::logic_error &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	}
 }
 
-void OBSBasic::on_actionRemoveSceneCollection_triggered()
+void OBSBasic::on_actionRenameSceneCollection_triggered()
 {
-	std::string newName;
-	std::string newPath;
-
-	std::string oldFile = config_get_string(App()->GlobalConfig(), "Basic",
-						"SceneCollectionFile");
-	std::string oldName = config_get_string(App()->GlobalConfig(), "Basic",
-						"SceneCollection");
-
-	auto cb = [&](const char *name, const char *filePath) {
-		if (strcmp(oldName.c_str(), name) != 0) {
-			newName = name;
-			newPath = filePath;
-			return false;
-		}
+	const OBSSceneCollection &currentCollection =
+		GetCurrentSceneCollection();
 
-		return true;
-	};
+	const OBSPromptCallback sceneCollectionCallback =
+		[this](const OBSPromptResult &result) {
+			if (GetSceneCollectionByName(result.promptValue)) {
+				return false;
+			}
 
-	EnumSceneCollections(cb);
+			return true;
+		};
 
-	/* this should never be true due to menu item being grayed out */
-	if (newPath.empty())
-		return;
+	const OBSPromptRequest request{
+		Str("Basic.Main.RenameSceneCollection.Title"),
+		Str("Basic.Main.AddSceneCollection.Text"),
+		currentCollection.name};
 
-	QString text =
-		QTStr("ConfirmRemove.Text").arg(QT_UTF8(oldName.c_str()));
+	OBSPromptResult result =
+		PromptForName(request, sceneCollectionCallback);
 
-	QMessageBox::StandardButton button = OBSMessageBox::question(
-		this, QTStr("ConfirmRemove.Title"), text);
-	if (button == QMessageBox::No)
+	if (!result.success) {
 		return;
+	}
+
+	try {
+		SetupRenameSceneCollection(result.promptValue);
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	} catch (const std::logic_error &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	}
+}
 
-	char path[512];
-	int ret = GetConfigPath(path, 512, "obs-studio/basic/scenes/");
-	if (ret <= 0) {
-		blog(LOG_WARNING, "Failed to get scene collection config path");
+void OBSBasic::on_actionRemoveSceneCollection_triggered(bool skipConfirmation)
+{
+	if (collections.size() < 2) {
 		return;
 	}
 
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
+	OBSSceneCollection currentCollection;
 
-	oldFile.insert(0, path);
-	/* os_rename() overwrites if necessary, only the .bak file will remain. */
-	os_rename((oldFile + ".json").c_str(), (oldFile + ".json.bak").c_str());
+	try {
+		currentCollection = GetCurrentSceneCollection();
 
-	Load(newPath.c_str());
-	RefreshSceneCollections();
+		if (!skipConfirmation) {
+			const QString confirmationText =
+				QTStr("ConfirmRemove.Text")
+					.arg(QString::fromStdString(
+						currentCollection.name));
+			const QMessageBox::StandardButton button =
+				OBSMessageBox::question(
+					this, QTStr("ConfirmRemove.Title"),
+					confirmationText);
 
-	const char *newFile = config_get_string(App()->GlobalConfig(), "Basic",
-						"SceneCollectionFile");
+			if (button == QMessageBox::No) {
+				return;
+			}
+		}
 
-	blog(LOG_INFO,
-	     "Removed scene collection '%s' (%s.json), "
-	     "switched to '%s' (%s.json)",
-	     oldName.c_str(), oldFile.c_str(), newName.c_str(), newFile);
-	blog(LOG_INFO, "------------------------------------------------");
+		OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
 
-	UpdateTitleBar();
+		collections.erase(currentCollection.name);
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	} catch (const std::logic_error &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	}
 
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_LIST_CHANGED);
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED);
+	const OBSSceneCollection &newCollection = collections.rbegin()->second;
+
+	ActivateSceneCollection(newCollection);
+	RemoveSceneCollection(currentCollection);
+
+	blog(LOG_INFO, "Switched to scene collection '%s' (%s)",
+	     newCollection.name.c_str(), newCollection.fileName.c_str());
+	blog(LOG_INFO, "------------------------------------------------");
 }
 
 void OBSBasic::on_actionImportSceneCollection_triggered()
@@ -364,37 +595,32 @@ void OBSBasic::on_actionImportSceneCollection_triggered()
 	OBSImporter imp(this);
 	imp.exec();
 
-	RefreshSceneCollections();
+	RefreshSceneCollections(true);
 }
 
 void OBSBasic::on_actionExportSceneCollection_triggered()
 {
 	SaveProjectNow();
 
-	char path[512];
-
-	QString home = QDir::homePath();
+	const OBSSceneCollection &currentCollection =
+		GetCurrentSceneCollection();
 
-	QString currentFile = QT_UTF8(config_get_string(
-		App()->GlobalConfig(), "Basic", "SceneCollectionFile"));
+	const QString home = QDir::homePath();
 
-	int ret = GetConfigPath(path, 512, "obs-studio/basic/scenes/");
-	if (ret <= 0) {
-		blog(LOG_WARNING, "Failed to get scene collection config path");
-		return;
-	}
-
-	QString exportFile =
+	const QString destinationFileName =
 		SaveFile(this, QTStr("Basic.MainMenu.SceneCollection.Export"),
-			 home + "/" + currentFile, "JSON Files (*.json)");
-
-	string file = QT_TO_UTF8(exportFile);
+			 home + "/" + currentCollection.fileName.c_str(),
+			 "JSON Files (*.json)");
 
-	if (!exportFile.isEmpty() && !exportFile.isNull()) {
-		QString inputFile = path + currentFile + ".json";
+	if (!destinationFileName.isEmpty() && !destinationFileName.isNull()) {
+		const std::filesystem::path sourceFile =
+			currentCollection.collectionFile;
+		const std::filesystem::path destinationFile =
+			std::filesystem::u8path(
+				destinationFileName.toStdString());
 
-		OBSDataAutoRelease collection =
-			obs_data_create_from_json_file(QT_TO_UTF8(inputFile));
+		OBSDataAutoRelease collection = obs_data_create_from_json_file(
+			sourceFile.u8string().c_str());
 
 		OBSDataArrayAutoRelease sources =
 			obs_data_get_array(collection, "sources");
@@ -403,15 +629,17 @@ void OBSBasic::on_actionExportSceneCollection_triggered()
 			     "No sources in exported scene collection");
 			return;
 		}
+
 		obs_data_erase(collection, "sources");
 
-		// We're just using std::sort on a vector to make life easier.
-		vector<OBSData> sourceItems;
+		using OBSDataVector = std::vector<OBSData>;
+
+		OBSDataVector sourceItems;
 		obs_data_array_enum(
 			sources,
-			[](obs_data_t *data, void *pVec) -> void {
-				auto &sourceItems =
-					*static_cast<vector<OBSData> *>(pVec);
+			[](obs_data_t *data, void *vector) -> void {
+				OBSDataVector &sourceItems{
+					*static_cast<OBSDataVector *>(vector)};
 				sourceItems.push_back(data);
 			},
 			&sourceItems);
@@ -425,12 +653,14 @@ void OBSBasic::on_actionExportSceneCollection_triggered()
 			  });
 
 		OBSDataArrayAutoRelease newSources = obs_data_array_create();
-		for (auto &item : sourceItems)
+		for (auto &item : sourceItems) {
 			obs_data_array_push_back(newSources, item);
+		}
 
 		obs_data_set_array(collection, "sources", newSources);
 		obs_data_save_json_pretty_safe(
-			collection, QT_TO_UTF8(exportFile), "tmp", "bak");
+			collection, destinationFile.u8string().c_str(), "tmp",
+			"bak");
 	}
 }
 
@@ -467,8 +697,10 @@ void OBSBasic::on_actionRemigrateSceneCollection_triggered()
 		return;
 	}
 
-	QString name = config_get_string(App()->GlobalConfig(), "Basic",
-					 "SceneCollection");
+	const OBSSceneCollection &currentCollection =
+		GetCurrentSceneCollection();
+
+	QString name = QString::fromStdString(currentCollection.name);
 	QString message = QTStr("Basic.Main.RemigrateSceneCollection.Text")
 				  .arg(name)
 				  .arg(ovi.base_width)
@@ -497,71 +729,43 @@ void OBSBasic::on_actionRemigrateSceneCollection_triggered()
 		}
 	}
 
-	char path[512];
-	int ret = GetConfigPath(path, 512, "obs-studio/basic/scenes/");
-	if (ret <= 0) {
-		blog(LOG_WARNING, "Failed to get scene collection config path");
-		return;
-	}
-
-	std::string fileName = path;
-	fileName += config_get_string(App()->GlobalConfig(), "Basic",
-				      "SceneCollectionFile");
-	fileName += ".json";
-
-	if (api)
-		api->on_event(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
 
 	/* Save and immediately reload to (re-)run migrations. */
 	SaveProjectNow();
 	/* Reset video if we potentially changed to a temporary resolution */
-	if (!usingAbsoluteCoordinates)
+	if (!usingAbsoluteCoordinates) {
 		ResetVideo();
+	}
 
-	Load(fileName.c_str(), !usingAbsoluteCoordinates);
-	RefreshSceneCollections();
-
-	if (api)
-		api->on_event(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED);
+	ActivateSceneCollection(currentCollection);
 }
 
-void OBSBasic::ChangeSceneCollection()
-{
-	QAction *action = reinterpret_cast<QAction *>(sender());
-	std::string fileName;
-
-	if (!action)
-		return;
-
-	fileName = QT_TO_UTF8(action->property("file_name").value<QString>());
-	if (fileName.empty())
-		return;
+// MARK: - Scene Collection Management Helper Functions
 
-	const char *oldName = config_get_string(App()->GlobalConfig(), "Basic",
-						"SceneCollection");
+void OBSBasic::ActivateSceneCollection(const OBSSceneCollection &collection)
+{
+	const std::string currentCollectionName{config_get_string(
+		App()->GetUserConfig(), "Basic", "SceneCollection")};
 
-	if (action->text().compare(QT_UTF8(oldName)) == 0) {
-		action->setChecked(true);
-		return;
+	if (auto foundCollection =
+		    GetSceneCollectionByName(currentCollectionName)) {
+		if (collection.name != foundCollection.value().name) {
+			SaveProjectNow();
+		}
 	}
 
-	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGING);
+	config_set_string(App()->GetUserConfig(), "Basic", "SceneCollection",
+			  collection.name.c_str());
+	config_set_string(App()->GetUserConfig(), "Basic",
+			  "SceneCollectionFile", collection.fileName.c_str());
 
-	SaveProjectNow();
+	Load(collection.collectionFile.u8string().c_str());
 
-	Load(fileName.c_str());
 	RefreshSceneCollections();
 
-	const char *newName = config_get_string(App()->GlobalConfig(), "Basic",
-						"SceneCollection");
-	const char *newFile = config_get_string(App()->GlobalConfig(), "Basic",
-						"SceneCollectionFile");
-
-	blog(LOG_INFO, "Switched to scene collection '%s' (%s.json)", newName,
-	     newFile);
-	blog(LOG_INFO, "------------------------------------------------");
-
 	UpdateTitleBar();
 
+	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_LIST_CHANGED);
 	OnEvent(OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED);
 }

+ 28 - 39
UI/window-basic-main.cpp

@@ -2332,29 +2332,13 @@ void OBSBasic::OBSInit()
 {
 	ProfileScope("OBSBasic::OBSInit");
 
-	const char *sceneCollection = config_get_string(
-		App()->GlobalConfig(), "Basic", "SceneCollectionFile");
-	char savePath[1024];
-	char fileName[1024];
-	int ret;
-
-	if (!sceneCollection)
-		throw "Failed to get scene collection name";
-
-	ret = snprintf(fileName, sizeof(fileName),
-		       "obs-studio/basic/scenes/%s.json", sceneCollection);
-	if (ret <= 0)
-		throw "Failed to create scene collection file name";
-
-	ret = GetConfigPath(savePath, sizeof(savePath), fileName);
-	if (ret <= 0)
-		throw "Failed to get scene collection json file path";
-
 	if (!InitBasicConfig())
 		throw "Failed to load basic.ini";
 	if (!ResetAudio())
 		throw "Failed to initialize audio";
 
+	int ret = 0;
+
 	ret = ResetVideo();
 
 	switch (ret) {
@@ -2401,6 +2385,12 @@ void OBSBasic::OBSInit()
 		AddExtraModulePaths();
 	}
 
+	/* Modules can access frontend information (i.e. profile and scene collection data) during their initialization, and some modules (e.g. obs-websockets) are known to use the filesystem location of the current profile in their own code.
+     
+     Thus the profile and scene collection discovery needs to happen before any access to that information (but after intializing global settings) to ensure legacy code gets valid path information.
+     */
+	RefreshSceneCollections(true);
+
 	blog(LOG_INFO, "---------------------------------");
 	obs_load_all_modules2(&mfi);
 	blog(LOG_INFO, "---------------------------------");
@@ -2482,7 +2472,19 @@ void OBSBasic::OBSInit()
 	{
 		ProfileScope("OBSBasic::Load");
 		disableSaving--;
-		Load(savePath);
+
+		try {
+			const OBSSceneCollection &currentCollection =
+				GetCurrentSceneCollection();
+			ActivateSceneCollection(currentCollection);
+		} catch (const std::invalid_argument &) {
+			const std::string collectionName =
+				config_get_string(App()->GetUserConfig(),
+						  "Basic", "SceneCollection");
+
+			SetupNewSceneCollection(collectionName);
+		}
+
 		disableSaving++;
 	}
 
@@ -2500,7 +2502,6 @@ void OBSBasic::OBSInit()
 					  Qt::QueuedConnection,
 					  Q_ARG(bool, true));
 
-	RefreshSceneCollections();
 	disableSaving--;
 
 	auto addDisplay = [this](OBSQTDisplay *window) {
@@ -3411,26 +3412,14 @@ void OBSBasic::SaveProjectDeferred()
 
 	projectChanged = false;
 
-	const char *sceneCollection = config_get_string(
-		App()->GlobalConfig(), "Basic", "SceneCollectionFile");
-
-	char savePath[1024];
-	char fileName[1024];
-	int ret;
-
-	if (!sceneCollection)
-		return;
-
-	ret = snprintf(fileName, sizeof(fileName),
-		       "obs-studio/basic/scenes/%s.json", sceneCollection);
-	if (ret <= 0)
-		return;
-
-	ret = GetConfigPath(savePath, sizeof(savePath), fileName);
-	if (ret <= 0)
-		return;
+	try {
+		const OBSSceneCollection &currentCollection =
+			GetCurrentSceneCollection();
 
-	Save(savePath);
+		Save(currentCollection.collectionFile.u8string().c_str());
+	} catch (const std::invalid_argument &error) {
+		blog(LOG_ERROR, "%s", error.what());
+	}
 }
 
 OBSSource OBSBasic::GetProgramSource()

+ 50 - 12
UI/window-basic-main.hpp

@@ -165,6 +165,8 @@ struct OBSPromptRequest {
 using OBSPromptCallback = std::function<bool(const OBSPromptResult &result)>;
 
 using OBSProfileCache = std::map<std::string, OBSProfile>;
+using OBSSceneCollectionCache = std::map<std::string, OBSSceneCollection>;
+
 class ColorSelect : public QWidget {
 
 public:
@@ -471,8 +473,6 @@ private:
 	void ToggleVolControlLayout();
 	void ToggleMixerLayout(bool vertical);
 
-	void RefreshSceneCollections();
-	void ChangeSceneCollection();
 	void LogScenes();
 	void SaveProjectNow();
 
@@ -760,8 +760,6 @@ public slots:
 			       bool manual = false);
 	void SetCurrentScene(OBSSource scene, bool force = false);
 
-	bool AddSceneCollection(bool create_new,
-				const QString &name = QString());
 	void UpdatePatronJson(const QString &text, const QString &error);
 
 	void ShowContextBar();
@@ -1163,14 +1161,6 @@ private slots:
 	void ProgramViewContextMenuRequested();
 	void on_previewDisabledWidget_customContextMenuRequested();
 
-	void on_actionNewSceneCollection_triggered();
-	void on_actionDupSceneCollection_triggered();
-	void on_actionRenameSceneCollection_triggered();
-	void on_actionRemoveSceneCollection_triggered();
-	void on_actionImportSceneCollection_triggered();
-	void on_actionExportSceneCollection_triggered();
-	void on_actionRemigrateSceneCollection_triggered();
-
 	void on_actionShowSettingsFolder_triggered();
 	void on_actionShowProfileFolder_triggered();
 
@@ -1398,6 +1388,54 @@ public slots:
 	bool CreateNewProfile(const QString &name);
 	bool CreateDuplicateProfile(const QString &name);
 	void DeleteProfile(const QString &profileName);
+
+	// MARK: - OBS Scene Collection Management
+private:
+	OBSSceneCollectionCache collections{};
+
+	void SetupNewSceneCollection(const std::string &collectionName);
+	void SetupDuplicateSceneCollection(const std::string &collectionName);
+	void SetupRenameSceneCollection(const std::string &collectionName);
+
+	const OBSSceneCollection &
+	CreateSceneCollection(const std::string &collectionName);
+	void RemoveSceneCollection(OBSSceneCollection collection);
+
+	bool CreateDuplicateSceneCollection(const QString &name);
+	void DeleteSceneCollection(const QString &name);
+	void ChangeSceneCollection();
+
+	void RefreshSceneCollectionCache();
+
+	void RefreshSceneCollections(bool refreshCache = false);
+	void ActivateSceneCollection(const OBSSceneCollection &collection);
+
+public:
+	inline const OBSSceneCollectionCache &
+	GetSceneCollectionCache() const noexcept
+	{
+		return collections;
+	};
+
+	const OBSSceneCollection &GetCurrentSceneCollection() const;
+
+	std::optional<OBSSceneCollection>
+	GetSceneCollectionByName(const std::string &collectionName) const;
+	std::optional<OBSSceneCollection>
+	GetSceneCollectionByFileName(const std::string &fileName) const;
+
+private slots:
+	void on_actionNewSceneCollection_triggered();
+	void on_actionDupSceneCollection_triggered();
+	void on_actionRenameSceneCollection_triggered();
+	void
+	on_actionRemoveSceneCollection_triggered(bool skipConfirmation = false);
+	void on_actionImportSceneCollection_triggered();
+	void on_actionExportSceneCollection_triggered();
+	void on_actionRemigrateSceneCollection_triggered();
+
+public slots:
+	bool CreateNewSceneCollection(const QString &name);
 };
 
 extern bool cef_js_avail;

+ 33 - 14
UI/window-importer.cpp

@@ -536,8 +536,11 @@ void OBSImporter::browseImport()
 
 bool GetUnusedName(std::string &name)
 {
-	if (!SceneCollectionExists(name.c_str()))
+	OBSBasic *basic = reinterpret_cast<OBSBasic *>(App()->GetMainWindow());
+
+	if (!basic->GetSceneCollectionByName(name)) {
 		return false;
+	}
 
 	std::string newName;
 	int inc = 2;
@@ -545,18 +548,21 @@ bool GetUnusedName(std::string &name)
 		newName = name;
 		newName += " ";
 		newName += std::to_string(inc++);
-	} while (SceneCollectionExists(newName.c_str()));
+	} while (basic->GetSceneCollectionByName(newName));
 
 	name = newName;
 	return true;
 }
 
+constexpr std::string_view OBSSceneCollectionPath = "obs-studio/basic/scenes/";
+
 void OBSImporter::importCollections()
 {
 	setEnabled(false);
 
-	char dst[512];
-	GetConfigPath(dst, 512, "obs-studio/basic/scenes/");
+	const std::filesystem::path sceneCollectionLocation =
+		App()->userScenesLocation /
+		std::filesystem::u8path(OBSSceneCollectionPath);
 
 	for (int i = 0; i < optionsModel->rowCount() - 1; i++) {
 		int selected = optionsModel->index(i, ImporterColumn::Selected)
@@ -591,22 +597,35 @@ void OBSImporter::importCollections()
 				out = newOut;
 			}
 
-			GetUnusedSceneCollectionFile(name, file);
+			std::string fileName;
+			if (!GetFileSafeName(name.c_str(), fileName)) {
+				blog(LOG_WARNING,
+				     "Failed to create safe file name for '%s'",
+				     fileName.c_str());
+			}
 
-			std::string save = dst;
-			save += "/";
-			save += file;
-			save += ".json";
+			std::string collectionFile;
+			collectionFile.reserve(
+				sceneCollectionLocation.u8string().size() +
+				fileName.size());
+			collectionFile
+				.append(sceneCollectionLocation.u8string())
+				.append(fileName);
+
+			if (!GetClosestUnusedFileName(collectionFile, "json")) {
+				blog(LOG_WARNING,
+				     "Failed to get closest file name for %s",
+				     fileName.c_str());
+			}
 
 			std::string out_str = json11::Json(out).dump();
 
-			bool success = os_quick_write_utf8_file(save.c_str(),
-								out_str.c_str(),
-								out_str.size(),
-								false);
+			bool success = os_quick_write_utf8_file(
+				collectionFile.c_str(), out_str.c_str(),
+				out_str.size(), false);
 
 			blog(LOG_INFO, "Import Scene Collection: %s (%s) - %s",
-			     name.c_str(), file.c_str(),
+			     name.c_str(), fileName.c_str(),
 			     success ? "SUCCESS" : "FAILURE");
 		}
 	}