diff --git a/js/module.d.ts b/js/module.d.ts index 45d5d76b8..dc1bb5614 100644 --- a/js/module.d.ts +++ b/js/module.d.ts @@ -1153,6 +1153,7 @@ interface INodeObs { [key: string]: any; readonly AutoOptimizer: IAutoOptimizer; OBS_API_initAPI(options: IOBSAPIInitializationOptions): EVideoCodes; + OBS_settings_saveSettings(category: string, settings: any[]): void; } export declare const enum VCamOutputType { Invalid = 0, diff --git a/js/module.ts b/js/module.ts index a6bea7120..293c70210 100644 --- a/js/module.ts +++ b/js/module.ts @@ -2453,6 +2453,20 @@ interface INodeObs { * @throws {Error} If the IPC call fails or OSN returns an error response without an initialization result */ OBS_API_initAPI(options: IOBSAPIInitializationOptions): EVideoCodes; + + /** + * Saves a complete legacy settings category, including native field metadata. + * Converting a common RTMP service to custom restores the exact native URL + * when the submitted server matches the previous form value. Inherited automatic + * selections are resolved to a URL; explicitly edited server values are preserved. + * @param category - Category name, such as `Stream` or `StreamSecond` + * @param settings - The `data` array from `OBS_settings_getSettings(category)`, + * with edited `currentValue` fields; keep the stream type field first + * @returns No value; read the category again to obtain normalized values and choices + * @throws {TypeError} If the settings metadata cannot be converted to the expected JavaScript types + * @throws {Error} If the IPC request fails or the server rejects the save + */ + OBS_settings_saveSettings(category: string, settings: any[]): void; } export const enum VCamOutputType { diff --git a/obs-studio-server/source/nodeobs_settings.cpp b/obs-studio-server/source/nodeobs_settings.cpp index 2d569a0e6..76793f399 100644 --- a/obs-studio-server/source/nodeobs_settings.cpp +++ b/obs-studio-server/source/nodeobs_settings.cpp @@ -607,6 +607,14 @@ void OBS_settings::saveGeneralSettings(std::vector generalSettings, config_close(config); } +static std::string getStreamSettingListValue(const char *value) +{ + std::string result = value; + if (!result.empty() && result.back() == '/') + result.pop_back(); + return result; +} + std::vector OBS_settings::getStreamSettings(StreamServiceId serviceId) { bool isCategoryEnabled = !OBS_service::isStreamingOutputActive(serviceId); @@ -744,14 +752,9 @@ std::vector OBS_settings::getStreamSettings(StreamServiceId service param.values.insert(param.values.end(), sizeNameBuffer.begin(), sizeNameBuffer.end()); param.values.insert(param.values.end(), name.begin(), name.end()); - std::string value = obs_property_list_item_string(property, i); + std::string value = getStreamSettingListValue(obs_property_list_item_string(property, i)); uint64_t sizeValue = value.length(); - if (value[sizeValue - 1] == '/') { - sizeValue--; - value.resize(sizeValue); - } - std::vector sizeValueBuffer; sizeValueBuffer.resize(sizeof(sizeValue)); memcpy(sizeValueBuffer.data(), &sizeValue, sizeof(sizeValue)); @@ -859,11 +862,12 @@ std::vector OBS_settings::getStreamSettings(StreamServiceId service memcpy(param.currentValue.data(), &value, sizeof(value)); param.sizeOfCurrentValue = sizeof(value); } else if (format == OBS_COMBO_FORMAT_STRING) { - currentServiceName = obs_data_get_string(settings, obs_property_name(property)); + std::string value = obs_data_get_string(settings, obs_property_name(property)); + if (param.name == "server" && strcmp(servType, "rtmp_common") == 0) + value = getStreamSettingListValue(value.c_str()); - param.currentValue.resize(strlen(currentServiceName)); - memcpy(param.currentValue.data(), currentServiceName, strlen(currentServiceName)); - param.sizeOfCurrentValue = strlen(currentServiceName); + param.currentValue.assign(value.begin(), value.end()); + param.sizeOfCurrentValue = value.size(); } } @@ -895,19 +899,18 @@ bool OBS_settings::saveStreamSettings(std::vector streamSettings, S if (!obs_service_is_ready_to_update(currentService)) return false; - obs_data_t *settings = nullptr; + OBSDataAutoRelease settings; std::string currentStreamType = obs_service_get_type(currentService); std::string newserviceTypeValue; - std::string currentServiceName = obs_data_get_string(obs_service_get_settings(currentService), "service"); - std::string newServiceValue; + OBSDataAutoRelease currentSettings = obs_service_get_settings(currentService); + std::string currentServiceName = obs_data_get_string(currentSettings, "service"); //get existing key to compare with new key later - if service is changed we will clear the key since keys don't carry over between services const char *currentKey = obs_service_get_connect_info(currentService, OBS_SERVICE_CONNECT_INFO_STREAM_KEY); SubCategory sc; - bool serviceChanged = false; bool serviceSettingsInvalid = false; for (int i = 0; i < streamSettings.size(); i++) { @@ -949,10 +952,6 @@ bool OBS_settings::saveStreamSettings(std::vector streamSettings, S serviceSettingsInvalid = true; break; } - newServiceValue = value; - if (currentServiceName.compare(newServiceValue) != 0) { - serviceChanged = true; - } } obs_data_set_string(settings, name.c_str(), value.c_str()); } else if (type.compare("OBS_PROPERTY_INT") == 0 || type.compare("OBS_PROPERTY_UINT") == 0) { @@ -968,64 +967,79 @@ bool OBS_settings::saveStreamSettings(std::vector streamSettings, S } } - if (serviceSettingsInvalid) { - if (settings) - obs_data_release(settings); + if (serviceSettingsInvalid || !settings) return false; + + if (currentStreamType == "rtmp_common" && newserviceTypeValue == "rtmp_custom") { + const std::string server = obs_data_get_string(settings, "server"); + const std::string currentServer = obs_data_get_string(currentSettings, "server"); + // Compare against the previous form value, which omits a trailing slash. + // Keep the submitted spelling intact when the user explicitly changes it. + if (!server.empty() && server == getStreamSettingListValue(currentServer.c_str())) { + if (currentServer.find("://") != std::string::npos) { + obs_data_set_string(settings, "server", currentServer.c_str()); + } else { + // Automatic selections belong to the common provider. Fresh profiles + // populate settings before updating the service, so resolve from that + // snapshot without mutating the current service. + OBSServiceAutoRelease resolver = obs_service_create("rtmp_common", "stream_settings_resolver", currentSettings, nullptr); + if (!resolver) + return false; + const char *resolvedServer = obs_service_get_connect_info(resolver, OBS_SERVICE_CONNECT_INFO_SERVER_URL); + if (!resolvedServer || std::string(resolvedServer).find("://") == std::string::npos) + return false; + obs_data_set_string(settings, "server", resolvedServer); + } + } } - obs_data_t *hotkeyData = obs_hotkeys_save_service(currentService); + OBSDataAutoRelease hotkeyData = obs_hotkeys_save_service(currentService); obs_service_t *newService = obs_service_create(newserviceTypeValue.c_str(), "default_service", settings, hotkeyData); + if (!newService) + return false; - if (serviceChanged) { - std::string server = obs_data_get_string(settings, "server"); - bool serverFound = false; - std::string defaultServer; - - // Check if server is valid + if (newserviceTypeValue == "rtmp_common") { + // Validate after applying the entire form: custom forms carry a server and + // key but no service field, so a type change alone must also reconcile them. + std::string server = getStreamSettingListValue(obs_data_get_string(settings, "server")); + std::string selectedServer; obs_properties_t *properties = obs_service_properties(newService); - obs_property_t *property = obs_properties_first(properties); - - while (property) { - std::string name = obs_property_name(property); - - if (name.compare("server") == 0) { - int count = (int)obs_property_list_item_count(property); - int i = 0; - - while (i < count && !serverFound) { - std::string value = obs_property_list_item_string(property, i); - - if (i == 0) - defaultServer = value; - - if (value.compare(server) == 0) - serverFound = true; + obs_property_t *property = obs_properties_get(properties, "server"); + const size_t count = obs_property_list_item_count(property); + for (size_t i = 0; i < count; i++) { + if (obs_property_list_item_disabled(property, i)) + continue; - i++; - } + // Match the form's normalized spelling, but retain the exact native + // endpoint, including any trailing slash in the catalog. + std::string value = obs_property_list_item_string(property, i); + if (value.empty()) + continue; + if (selectedServer.empty()) + selectedServer = value; + if (getStreamSettingListValue(value.c_str()) == server) { + selectedServer = value; + break; } - obs_property_next(&property); } + obs_properties_destroy(properties); - if (!serverFound && defaultServer.compare("") != 0) { - // Server not found, we set the default server - obs_data_set_string(settings, "server", defaultServer.c_str()); - obs_service_update(newService, settings); + if (selectedServer.empty()) { + obs_service_release(newService); + return false; } + obs_data_set_string(settings, "server", selectedServer.c_str()); - //if service changed but key didn't we also need to clear out the key since services don't share keys + const bool serviceChanged = currentStreamType != newserviceTypeValue || currentServiceName != obs_data_get_string(settings, "service"); + // An unchanged key belongs to the previous destination. An explicitly + // supplied replacement key may be used with the newly selected service. const char *newKey = obs_data_get_string(settings, "key"); - if (currentKey != nullptr && newKey != nullptr && strcmp(currentKey, newKey) == 0) { - blog(LOG_INFO, "MLH clearing stream key since service was changed and key wasn't"); + if (serviceChanged && currentKey != nullptr && newKey != nullptr && strcmp(currentKey, newKey) == 0) obs_data_set_string(settings, "key", ""); - obs_service_update(newService, settings); - } + obs_service_update(newService, settings); } - obs_data_release(hotkeyData); - OBS_service::setService(newService, serviceId); obs_data_t *data = obs_data_create(); @@ -1036,7 +1050,6 @@ bool OBS_settings::saveStreamSettings(std::vector streamSettings, S blog(LOG_WARNING, "Failed to save service"); } - obs_data_release(hotkeyData); obs_data_release(data); return true; } diff --git a/tests/osn-tests/src/test_nodeobs_settings.ts b/tests/osn-tests/src/test_nodeobs_settings.ts index 82b0c5181..edfa06b3a 100644 --- a/tests/osn-tests/src/test_nodeobs_settings.ts +++ b/tests/osn-tests/src/test_nodeobs_settings.ts @@ -1,5 +1,7 @@ import 'mocha' import { expect } from 'chai' +import * as fs from 'fs'; +import * as path from 'path'; import * as osn from '../osn'; import { logInfo, logEmptyLine } from '../util/logger'; import { ETestErrorMsg, GetErrorMessage } from '../util/error_messages'; @@ -117,6 +119,254 @@ describe(testName, function() { }); }); + for (const category of [EOBSSettingsCategories.Stream, EOBSSettingsCategories.StreamSecond]) { + describe(`${category} server normalization`, function() { + const youtubeServer = 'rtmps://a.rtmps.youtube.com:443/live2'; + const dummyKey = 'synthetic-stream-settings-key'; + const configFile = category === EOBSSettingsCategories.Stream ? 'service.json' : 'service1.json'; + const configPath = path.join(__dirname, '..', 'osnData', 'slobs-client', configFile); + let originalSettings: any[]; + + function readSettings(): any[] { + return osn.NodeObs.OBS_settings_getSettings(category).data; + } + + function field(name: string, settings = readSettings()): any { + const parameters = settings.reduce((parameters, section) => parameters.concat(section.parameters), []); + const parameter = parameters.find(parameter => parameter.name === name); + expect(parameter, `Missing ${category} setting ${name}`).to.not.equal(undefined); + return parameter; + } + + function save(changes: osn.ISettings) { + // The generic Desktop form submits the complete metadata with only the + // edited values changed, including stale server/key fields on type changes. + const settings = readSettings(); + for (const name of Object.keys(changes)) { + field(name, settings).currentValue = changes[name]; + } + osn.NodeObs.OBS_settings_saveSettings(category, settings); + } + + function serverChoices(): string[] { + return field('server').values.map(value => value[Object.keys(value)[0]]); + } + + function expectSaved(changes: osn.ISettings, persistedServer?: string) { + const settings = readSettings(); + const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')); + for (const name of Object.keys(changes)) { + expect(field(name, settings).currentValue, `Returned ${category} ${name}`).to.equal(changes[name]); + const expectedPersisted = name === 'server' && persistedServer !== undefined ? persistedServer : changes[name]; + expect(name === 'streamType' ? persisted.type : persisted.settings[name], `Persisted ${category} ${name}`).to.equal(expectedPersisted); + } + } + + function setCustomServer(server = youtubeServer) { + save({ streamType: 'rtmp_custom' }); + save({ server, key: dummyKey }); + } + + beforeEach(function() { + originalSettings = readSettings(); + save({ streamType: 'rtmp_custom' }); + save({ streamType: 'rtmp_common' }); + save({ service: 'Twitch', server: 'auto', key: '' }); + }); + + afterEach(function() { + // A destination change may clear an unchanged key on the first save. + // Restore once more with the original destination already selected. + osn.NodeObs.OBS_settings_saveSettings(category, originalSettings); + osn.NodeObs.OBS_settings_saveSettings(category, originalSettings); + }); + + it('Replaces the custom YouTube server and carried key when switching to common streaming', function() { + setCustomServer(); + save({ streamType: 'rtmp_common' }); + + expect(serverChoices()).to.include('auto').and.not.include(youtubeServer); + expectSaved({ streamType: 'rtmp_common', service: 'Twitch', server: 'auto', key: '' }); + }); + + it('Keeps an explicitly replaced key when switching from custom to common streaming', function() { + setCustomServer(); + save({ streamType: 'rtmp_common', key: 'synthetic-replacement-key' }); + + expectSaved({ service: 'Twitch', server: 'auto', key: 'synthetic-replacement-key' }); + }); + + it('Replaces an invalid server and unchanged key when changing the common provider', function() { + save({ service: 'YouTube - RTMPS' }); + save({ server: youtubeServer, key: dummyKey }); + save({ service: 'Twitch' }); + + expect(serverChoices()).to.include('auto').and.not.include(youtubeServer); + expectSaved({ service: 'Twitch', server: 'auto', key: '' }); + }); + + it('Keeps an explicitly replaced key when changing the common provider', function() { + save({ service: 'YouTube - RTMPS' }); + save({ server: youtubeServer, key: dummyKey }); + save({ service: 'Twitch', key: 'synthetic-replacement-key' }); + + expectSaved({ service: 'Twitch', server: 'auto', key: 'synthetic-replacement-key' }); + }); + + it('Preserves a manually selected common server and key on unrelated saves', function() { + save({ service: 'YouTube - RTMPS' }); + const choices = serverChoices(); + expect(choices.length, 'Expected a non-default YouTube ingest').to.be.greaterThan(1); + const selectedServer = choices[1]; + save({ server: selectedServer, key: dummyKey }); + save({ show_all: !field('show_all').currentValue }); + + expectSaved({ service: 'YouTube - RTMPS', server: selectedServer, key: dummyKey }); + }); + + it('Preserves a non-default catalog server whose metadata strips its trailing slash', function() { + save({ show_all: true }); + save({ service: 'Nimo TV' }); + const choices = serverChoices(); + expect(choices.length, 'Expected a non-default Nimo ingest').to.be.greaterThan(1); + const selectedServer = choices[1]; + expect(selectedServer.endsWith('/')).to.equal(false); + save({ server: selectedServer, key: dummyKey }); + expectSaved({ service: 'Nimo TV', server: selectedServer, key: dummyKey }, `${selectedServer}/`); + + // The persisted native spelling and the form spelling select the same + // server; the returned value must always belong to the returned choices. + save({ server: `${selectedServer}/` }); + expect(serverChoices()).to.include(selectedServer); + expectSaved({ service: 'Nimo TV', server: selectedServer, key: dummyKey }, `${selectedServer}/`); + }); + + it('Keeps the Facebook catalog URL intact while returning a matching server choice', function() { + save({ service: 'Facebook Live' }); + const choices = serverChoices(); + expect(choices.length, 'Expected a Facebook ingest').to.be.greaterThan(0); + const selectedServer = choices[0]; + expect(selectedServer.endsWith('/rtmp')).to.equal(true); + + // The Facebook catalog URL includes a final slash that the generic + // form omits from the displayed choice. + save({ server: selectedServer, key: dummyKey }); + expectSaved({ service: 'Facebook Live', server: selectedServer, key: dummyKey }, `${selectedServer}/`); + save({ show_all: true }); + expect(serverChoices()).to.include(field('server').currentValue); + expectSaved({ service: 'Facebook Live', server: selectedServer, key: dummyKey }, `${selectedServer}/`); + }); + + it('Preserves an arbitrary custom URL and key on unrelated saves', function() { + const customServer = 'rtmps://custom.example.invalid:443/application/'; + setCustomServer(customServer); + save({ use_auth: !field('use_auth').currentValue }); + + expectSaved({ streamType: 'rtmp_custom', server: customServer, key: dummyKey }); + }); + + it('Preserves the common server and key when switching to custom streaming', function() { + save({ service: 'YouTube - RTMPS' }); + save({ server: youtubeServer, key: dummyKey }); + save({ streamType: 'rtmp_custom' }); + + expectSaved({ streamType: 'rtmp_custom', server: youtubeServer, key: dummyKey }); + }); + + it('Preserves the Facebook catalog URL when switching to custom streaming', function() { + save({ service: 'Facebook Live' }); + save({ key: dummyKey }); + const formServer = field('server').currentValue; + const nativeServer = JSON.parse(fs.readFileSync(configPath, 'utf8')).settings.server; + expect(nativeServer).to.equal(`${formServer}/`); + + save({ streamType: 'rtmp_custom' }); + + expectSaved({ streamType: 'rtmp_custom', server: nativeServer, key: dummyKey }); + + // After conversion the custom field exposes the exact URL, so a + // deliberate removal of its trailing slash must remain possible. + save({ server: formServer }); + expectSaved({ streamType: 'rtmp_custom', server: formServer, key: dummyKey }); + }); + + it('Keeps an explicit replacement URL when leaving Facebook common streaming', function() { + save({ service: 'Facebook Live' }); + save({ key: dummyKey }); + const server = 'rtmps://custom.example.invalid:443/application/'; + + save({ streamType: 'rtmp_custom', server }); + + expectSaved({ streamType: 'rtmp_custom', server, key: dummyKey }); + }); + + it('Keeps an explicitly added trailing slash when switching to custom streaming', function() { + save({ service: 'YouTube - RTMPS' }); + save({ server: youtubeServer, key: dummyKey }); + expect(field('server').currentValue).to.equal(youtubeServer); + const server = `${youtubeServer}/`; + + save({ streamType: 'rtmp_custom', server }); + + expectSaved({ streamType: 'rtmp_custom', server, key: dummyKey }); + }); + + it('Resolves the automatic Twitch server when switching to custom streaming', function() { + save({ key: dummyKey }); + expect(field('server').currentValue).to.equal('auto'); + save({ streamType: 'rtmp_custom' }); + + const server = field('server').currentValue; + expect(server).to.match(/^rtmps?:\/\/[^/]+\/.+/); + expectSaved({ streamType: 'rtmp_custom', server, key: dummyKey }); + }); + + it('Keeps an explicitly supplied custom URL instead of resolving the automatic server', function() { + save({ key: dummyKey }); + const server = 'rtmps://custom.example.invalid:443/application/'; + save({ streamType: 'rtmp_custom', server }); + + expectSaved({ streamType: 'rtmp_custom', server, key: dummyKey }); + }); + + it('Preserves a regional Twitch server when switching to custom streaming', function() { + const server = serverChoices().find(value => /^rtmps?:\/\//.test(value)); + expect(server, 'Expected a concrete Twitch ingest').to.be.a('string'); + save({ server, key: dummyKey }); + save({ streamType: 'rtmp_custom' }); + + expectSaved({ streamType: 'rtmp_custom', server, key: dummyKey }); + }); + + it('Repairs a stale common server submitted without a provider change', function() { + save({ key: dummyKey }); + expect(field('service').currentValue).to.equal('Twitch'); + expect(serverChoices()).to.not.include(youtubeServer); + + // Re-submit the mismatched form produced by older settings saves, + // keeping both the common stream type and provider unchanged. + save({ server: youtubeServer, show_all: true }); + + expectSaved({ service: 'Twitch', server: 'auto', key: dummyKey }); + }); + + it('Rejects an unknown common provider without replacing or persisting the current settings', function() { + save({ key: dummyKey }); + const previousSettings = readSettings(); + const previousFile = fs.readFileSync(configPath, 'utf8'); + + expect(() => save({ + service: 'synthetic-unknown-stream-provider', + server: youtubeServer, + key: 'synthetic-replacement-key', + })).to.throw(Error); + + expect(readSettings()).to.deep.equal(previousSettings); + expect(fs.readFileSync(configPath, 'utf8')).to.equal(previousFile); + }); + }); + } + it('Get and set simple output settings', function() { obs.setSetting(EOBSSettingsCategories.Output, 'Mode', 'Simple'); obs.setSetting(EOBSSettingsCategories.Output, 'RecQuality', 'Stream');