From 5113b5b69bee1d04d293a406dab8798f5b27400c Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Tue, 16 Jun 2026 18:09:16 +0100 Subject: [PATCH 1/3] Show NDI runtime module load notifications Added possibility to handle any OBS modules load errors. --- app/app-services.ts | 3 + app/services/app/app.ts | 3 + .../notifications/notifications-api.ts | 1 + app/services/notifications/notifications.ts | 13 ++ .../obs-module-load-notifications-service.ts | 132 +++++++++++++ app/services/obs-module-load-notifications.ts | 74 +++++++ test/regular/obs-module-load-notifications.ts | 180 ++++++++++++++++++ 7 files changed, 406 insertions(+) create mode 100644 app/services/obs-module-load-notifications-service.ts create mode 100644 app/services/obs-module-load-notifications.ts create mode 100644 test/regular/obs-module-load-notifications.ts diff --git a/app/app-services.ts b/app/app-services.ts index 732d5d092fdd..cbaea50c47db 100644 --- a/app/app-services.ts +++ b/app/app-services.ts @@ -20,6 +20,7 @@ export { ShortcutsService } from 'services/shortcuts'; export { CustomizationService } from 'services/customization'; export { LayoutService } from 'services/layout'; export { NotificationsService } from 'services/notifications'; +export { ObsModuleLoadNotificationsService } from 'services/obs-module-load-notifications-service'; export { OnboardingService } from 'services/onboarding'; export { NavigationService } from 'services/navigation'; export { PerformanceService } from 'services/performance'; @@ -163,6 +164,7 @@ import { TcpServerService } from './services/api/tcp-server'; import { MagicLinkService } from './services/magic-link'; import { UsageStatisticsService } from './services/usage-statistics'; import { NotificationsService } from './services/notifications'; +import { ObsModuleLoadNotificationsService } from 'services/obs-module-load-notifications-service'; import { MediaBackupService } from './services/media-backup'; import { HotkeysService } from './services/hotkeys'; import { WidgetsService } from './services/widgets'; @@ -257,6 +259,7 @@ export const AppServices = { MediaGalleryService, UsageStatisticsService, NotificationsService, + ObsModuleLoadNotificationsService, MediaBackupService, HotkeysService, WidgetsService, diff --git a/app/services/app/app.ts b/app/services/app/app.ts index 5f5685296d0e..d0d7aae94da0 100644 --- a/app/services/app/app.ts +++ b/app/services/app/app.ts @@ -49,6 +49,7 @@ import { NavigationService } from 'services/navigation'; import { StreamingService } from 'services/streaming'; import { VirtualWebcamService } from 'services/virtual-webcam'; import { WebsocketService } from 'services/websocket'; +import { ObsModuleLoadNotificationsService } from 'services/obs-module-load-notifications-service'; interface IAppState { loading: boolean; @@ -106,6 +107,7 @@ export class AppService extends StatefulService { @Inject() private streamingService: StreamingService; @Inject() private virtualWebcamService: VirtualWebcamService; @Inject() private websocketService: WebsocketService; + @Inject() private obsModuleLoadNotificationsService: ObsModuleLoadNotificationsService; static initialState: IAppState = { loading: true, @@ -157,6 +159,7 @@ export class AppService extends StatefulService { // TODO: We should come up with a better way to handle this. await this.sceneCollectionsService.initialize(); } + await this.obsModuleLoadNotificationsService.refreshModuleLoadNotifications(); if (this.userService.isAlphaGroup) { this.SET_ONBOARDED(this.onboardingService.startOnboardingIfRequired()); diff --git a/app/services/notifications/notifications-api.ts b/app/services/notifications/notifications-api.ts index 34e8b30e62db..d6b026d39221 100644 --- a/app/services/notifications/notifications-api.ts +++ b/app/services/notifications/notifications-api.ts @@ -69,6 +69,7 @@ export interface INotificationsServiceApi { restoreDefaultSettings(): void; markAsRead(id: number): void; markAllAsRead(): void; + removeByCode(code: string): void; applyAction(notificationId: number): void; showNotifications(): void; } diff --git a/app/services/notifications/notifications.ts b/app/services/notifications/notifications.ts index 123b364f9b80..5e7c0f767502 100644 --- a/app/services/notifications/notifications.ts +++ b/app/services/notifications/notifications.ts @@ -143,6 +143,14 @@ export class NotificationsService this.notificationRead.next(unreadNotifies.map(notify => notify.id)); } + removeByCode(code: string) { + const matchingNotifications = this.views.getAll().filter(notify => notify.code === code); + if (!matchingNotifications.length) return; + + this.REMOVE_BY_CODE(code); + this.notificationRead.next(matchingNotifications.map(notify => notify.id)); + } + setSettings(patch: Partial) { this.SET_SETTINGS(patch); } @@ -177,6 +185,11 @@ export class NotificationsService this.state.notifications = []; } + @mutation() + private REMOVE_BY_CODE(code: string) { + this.state.notifications = this.state.notifications.filter(notify => notify.code !== code); + } + @mutation() private MARK_ALL_AS_READ() { this.state.notifications.forEach(notify => (notify.unread = false)); diff --git a/app/services/obs-module-load-notifications-service.ts b/app/services/obs-module-load-notifications-service.ts new file mode 100644 index 000000000000..874d4ad490cf --- /dev/null +++ b/app/services/obs-module-load-notifications-service.ts @@ -0,0 +1,132 @@ +import * as remote from '@electron/remote'; +import * as obs from '../../obs-api'; +import { Inject } from 'services/core/injector'; +import { Service } from 'services/core/service'; +import { JsonrpcService } from 'services/api/jsonrpc'; +import { $t } from 'services/i18n'; +import { ENotificationType, NotificationsService } from 'services/notifications'; +import { SceneCollectionsService } from 'services/scene-collections'; +import { + NDI_RUNTIME_FAILURE_CODES, + findNdiRuntimeLoadFailure, + getNdiRuntimeNotificationMessage, + IObsModuleLoadFailure, + shouldShowNdiRuntimeNotification, +} from 'services/obs-module-load-notifications'; + +const NDI_RUNTIME_DOWNLOAD_URL = 'https://ndi.video/tools/download'; + +export class ObsModuleLoadNotificationsService extends Service { + @Inject() private notificationsService: NotificationsService; + @Inject() private jsonrpcService: JsonrpcService; + @Inject() private sceneCollectionsService: SceneCollectionsService; + + private moduleLoadFailures: IObsModuleLoadFailure[] | null = null; + private refreshId = 0; + + init() { + this.sceneCollectionsService.collectionSwitched.subscribe(() => { + this.refreshAfterActiveCollectionChanged(); + }); + + this.sceneCollectionsService.collectionUpdated.subscribe(collection => { + if (collection.id === this.sceneCollectionsService.activeCollection?.id) { + this.refreshAfterActiveCollectionChanged(); + } + }); + } + + async refreshModuleLoadNotifications(failures?: IObsModuleLoadFailure[]) { + if (failures) { + this.moduleLoadFailures = failures; + } else if (!this.moduleLoadFailures) { + this.moduleLoadFailures = this.getModuleLoadFailures(); + } + + const refreshId = ++this.refreshId; + const moduleLoadFailures = this.moduleLoadFailures || []; + + const ndiRuntimeLoadFailure = findNdiRuntimeLoadFailure(moduleLoadFailures); + if (!ndiRuntimeLoadFailure) { + this.clearNdiRuntimeNotifications(); + return; + } + + const shouldShowNotification = await this.shouldShowNdiRuntimeNotification(moduleLoadFailures); + if (refreshId !== this.refreshId) { + return; + } + + if (!shouldShowNotification) { + this.clearNdiRuntimeNotifications(); + return; + } + + this.clearNdiRuntimeNotifications(ndiRuntimeLoadFailure.code); + this.notificationsService.push({ + action: this.jsonrpcService.createRequest( + Service.getResourceId(this), + 'openNdiRuntimeDownloadPage', + ), + code: ndiRuntimeLoadFailure.code, + data: ndiRuntimeLoadFailure, + lifeTime: -1, + message: $t(getNdiRuntimeNotificationMessage(ndiRuntimeLoadFailure.code)), + singleton: true, + type: ENotificationType.WARNING, + }); + } + + openNdiRuntimeDownloadPage() { + remote.shell.openExternal(NDI_RUNTIME_DOWNLOAD_URL); + } + + private refreshAfterActiveCollectionChanged() { + if (!this.moduleLoadFailures) return; + + this.refreshModuleLoadNotifications().catch((e: unknown) => { + console.warn( + '[ObsModuleLoadNotifications] Failed to refresh module load notifications after scene collection change.', + e, + ); + }); + } + + private getModuleLoadFailures(): IObsModuleLoadFailure[] { + const getModuleLoadFailures = obs.NodeObs.OBS_API_getModuleLoadFailures; + if (typeof getModuleLoadFailures !== 'function') { + console.warn('[ObsModuleLoadNotifications] OBS_API_getModuleLoadFailures is unavailable.'); + return []; + } + + return getModuleLoadFailures(); + } + + private async shouldShowNdiRuntimeNotification( + failures: IObsModuleLoadFailure[], + ): Promise { + try { + const activeCollectionId = this.sceneCollectionsService.activeCollection?.id; + const sceneCollections = await this.sceneCollectionsService.fetchSceneCollectionsSchema(); + const shouldShowNotification = shouldShowNdiRuntimeNotification( + failures, + sceneCollections, + activeCollectionId, + ); + + return shouldShowNotification; + } catch (e: unknown) { + console.warn( + '[ObsModuleLoadNotifications] Failed to inspect active scene collection for NDI sources.', + e, + ); + return false; + } + } + + private clearNdiRuntimeNotifications(exceptCode?: string) { + NDI_RUNTIME_FAILURE_CODES.forEach(code => { + if (code !== exceptCode) this.notificationsService.removeByCode(code); + }); + } +} diff --git a/app/services/obs-module-load-notifications.ts b/app/services/obs-module-load-notifications.ts new file mode 100644 index 000000000000..2f5ed65ea37a --- /dev/null +++ b/app/services/obs-module-load-notifications.ts @@ -0,0 +1,74 @@ +import type { IObsModuleLoadFailure } from 'obs-studio-node'; +import type * as ObsStudioNode from 'obs-studio-node'; +import type { ISceneCollectionSchema } from 'services/scene-collections'; + +export type { IObsModuleLoadFailure }; + +export const NDI_RUNTIME_VERSION_MISMATCH: typeof ObsStudioNode.NDI_RUNTIME_VERSION_MISMATCH = + 'NDI_RUNTIME_VERSION_MISMATCH'; +export const NDI_RUNTIME_NOT_FOUND: typeof ObsStudioNode.NDI_RUNTIME_NOT_FOUND = + 'NDI_RUNTIME_NOT_FOUND'; + +export const NDI_RUNTIME_FAILURE_CODES: readonly string[] = [ + NDI_RUNTIME_VERSION_MISMATCH, + NDI_RUNTIME_NOT_FOUND, +]; + +function normalizeModuleName(moduleName: string): string { + return (moduleName || '').replace(/\.(dll|so|dylib)$/i, '').toLowerCase(); +} + +function isObsNdiRuntimeFailure(failure: IObsModuleLoadFailure): boolean { + return ( + normalizeModuleName(failure.module) === 'obs-ndi' && + NDI_RUNTIME_FAILURE_CODES.includes(failure.code) + ); +} + +export function findNdiRuntimeVersionMismatch( + failures: IObsModuleLoadFailure[], +): IObsModuleLoadFailure | null { + return ( + failures.find(failure => { + return ( + normalizeModuleName(failure.module) === 'obs-ndi' && + failure.code === NDI_RUNTIME_VERSION_MISMATCH + ); + }) || null + ); +} + +export function findNdiRuntimeLoadFailure( + failures: IObsModuleLoadFailure[], +): IObsModuleLoadFailure | null { + return failures.find(isObsNdiRuntimeFailure) || null; +} + +export function getNdiRuntimeNotificationMessage(code: string): string { + if (code === NDI_RUNTIME_NOT_FOUND) { + return 'NDI Runtime was not found. Install NDI Tools to use NDI sources and outputs.'; + } + + return 'NDI Runtime 6 or newer is required for NDI sources and outputs. Click to download the latest NDI Runtime.'; +} + +export function activeSceneCollectionHasNdiSources( + sceneCollections: ISceneCollectionSchema[], + activeCollectionId?: string, +): boolean { + if (!activeCollectionId) return false; + + const activeCollection = sceneCollections.find(collection => collection.id === activeCollectionId); + return activeCollection?.sources.some(source => source.type === 'ndi_source') || false; +} + +export function shouldShowNdiRuntimeNotification( + failures: IObsModuleLoadFailure[], + sceneCollections: ISceneCollectionSchema[], + activeCollectionId?: string, +): boolean { + return ( + !!findNdiRuntimeLoadFailure(failures) && + activeSceneCollectionHasNdiSources(sceneCollections, activeCollectionId) + ); +} diff --git a/test/regular/obs-module-load-notifications.ts b/test/regular/obs-module-load-notifications.ts new file mode 100644 index 000000000000..12a8ebc70748 --- /dev/null +++ b/test/regular/obs-module-load-notifications.ts @@ -0,0 +1,180 @@ +import test from 'ava'; +import { + NDI_RUNTIME_NOT_FOUND, + NDI_RUNTIME_VERSION_MISMATCH, + activeSceneCollectionHasNdiSources, + findNdiRuntimeLoadFailure, + findNdiRuntimeVersionMismatch, + getNdiRuntimeNotificationMessage, + shouldShowNdiRuntimeNotification, +} from '../../app/services/obs-module-load-notifications'; +import type { ISceneCollectionSchema } from '../../app/services/scene-collections'; + +function sceneCollection( + id: string, + sources: Array<{ sourceId: string; name: string; type: string; channel?: number }>, +): ISceneCollectionSchema { + return { + id, + name: id, + scenes: [], + sources: sources.map(source => ({ + ...source, + channel: source.channel ?? 0, + })), + }; +} + +test('findNdiRuntimeVersionMismatch returns obs-ndi version mismatch failure', t => { + const failure = { + module: 'obs-ndi', + code: NDI_RUNTIME_VERSION_MISMATCH, + message: 'Installed NDI Runtime version 5.6.0 is not supported.', + }; + + t.is( + findNdiRuntimeVersionMismatch([ + { module: 'other-plugin', code: NDI_RUNTIME_VERSION_MISMATCH, message: 'ignore' }, + failure, + ]), + failure, + ); +}); + +test('findNdiRuntimeVersionMismatch handles obs-ndi module filenames', t => { + const failure = { + module: 'obs-ndi.dll', + code: NDI_RUNTIME_VERSION_MISMATCH, + message: 'Installed NDI Runtime version 5.6.0 is not supported.', + }; + + t.is(findNdiRuntimeVersionMismatch([failure]), failure); +}); + +test('findNdiRuntimeLoadFailure returns obs-ndi runtime not found failure', t => { + const failure = { + module: 'obs-ndi', + code: NDI_RUNTIME_NOT_FOUND, + message: 'NDI Runtime 6 or newer was not found.', + }; + + t.is(findNdiRuntimeLoadFailure([failure]), failure); +}); + +test('findNdiRuntimeVersionMismatch ignores unrelated module failures', t => { + t.is( + findNdiRuntimeVersionMismatch([ + { module: 'obs-ndi', code: 'NDI_RUNTIME_NOT_FOUND', message: 'ignore' }, + { module: 'other-plugin', code: NDI_RUNTIME_VERSION_MISMATCH, message: 'ignore' }, + ]), + null, + ); +}); + +test('getNdiRuntimeNotificationMessage asks users to install missing runtime', t => { + t.is( + getNdiRuntimeNotificationMessage(NDI_RUNTIME_NOT_FOUND), + 'NDI Runtime was not found. Install NDI Tools to use NDI sources and outputs.', + ); +}); + +test('getNdiRuntimeNotificationMessage asks users to upgrade old runtime', t => { + t.is( + getNdiRuntimeNotificationMessage(NDI_RUNTIME_VERSION_MISMATCH), + 'NDI Runtime 6 or newer is required for NDI sources and outputs. Click to download the latest NDI Runtime.', + ); +}); + +test('activeSceneCollectionHasNdiSources returns true for active collection with ndi source', t => { + t.true( + activeSceneCollectionHasNdiSources( + [ + sceneCollection('active', [ + { sourceId: 'camera', name: 'Camera', type: 'ndi_source' }, + { sourceId: 'browser', name: 'Browser', type: 'browser_source' }, + ]), + ], + 'active', + ), + ); +}); + +test('activeSceneCollectionHasNdiSources ignores ndi sources in inactive collections', t => { + t.false( + activeSceneCollectionHasNdiSources( + [ + sceneCollection('active', [ + { sourceId: 'browser', name: 'Browser', type: 'browser_source' }, + ]), + sceneCollection('inactive', [ + { sourceId: 'camera', name: 'Camera', type: 'ndi_source' }, + ]), + ], + 'active', + ), + ); +}); + +test('activeSceneCollectionHasNdiSources returns false without active collection id', t => { + t.false( + activeSceneCollectionHasNdiSources( + [ + sceneCollection('active', [{ sourceId: 'camera', name: 'Camera', type: 'ndi_source' }]), + ], + undefined, + ), + ); +}); + +test('shouldShowNdiRuntimeNotification returns true for ndi runtime failure in active ndi collection', t => { + t.true( + shouldShowNdiRuntimeNotification( + [ + { + module: 'obs-ndi', + code: NDI_RUNTIME_NOT_FOUND, + message: 'NDI Runtime 6 or newer was not found.', + }, + ], + [ + sceneCollection('active', [{ sourceId: 'camera', name: 'Camera', type: 'ndi_source' }]), + ], + 'active', + ), + ); +}); + +test('shouldShowNdiRuntimeNotification returns false for ndi runtime failure in active non-ndi collection', t => { + t.false( + shouldShowNdiRuntimeNotification( + [ + { + module: 'obs-ndi', + code: NDI_RUNTIME_NOT_FOUND, + message: 'NDI Runtime 6 or newer was not found.', + }, + ], + [ + sceneCollection('active', [ + { sourceId: 'browser', name: 'Browser', type: 'browser_source' }, + ]), + sceneCollection('inactive', [ + { sourceId: 'camera', name: 'Camera', type: 'ndi_source' }, + ]), + ], + 'active', + ), + ); +}); + +test('shouldShowNdiRuntimeNotification returns false for active ndi collection without ndi runtime failure', t => { + t.false( + shouldShowNdiRuntimeNotification( + [{ module: 'other-plugin', code: 'MODULE_LOAD_FAILED', message: 'ignore' }], + [ + sceneCollection('active', [{ sourceId: 'camera', name: 'Camera', type: 'ndi_source' }]), + ], + 'active', + ), + ); +}); From b246203f591bba6334dbc255cda5db0aadba7281 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Tue, 16 Jun 2026 18:47:42 +0100 Subject: [PATCH 2/3] OSN version update --- scripts/repositories.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/repositories.json b/scripts/repositories.json index 74596505e8cd..0b3d5598f02b 100644 --- a/scripts/repositories.json +++ b/scripts/repositories.json @@ -4,7 +4,7 @@ "name": "obs-studio-node", "url": "https://s3-us-west-2.amazonaws.com/obsstudionodes3.streamlabs.com/", "archive": "osn-[VERSION]-release-[OS][ARCH].tar.gz", - "version": "0.26.29b3", + "version": "ndi-error-handling-1", "win64": true, "osx": true }, From f678e595a5122db6493427f0970db7efbe2e1794 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Tue, 16 Jun 2026 21:35:17 +0100 Subject: [PATCH 3/3] Fixed eslint --- app/services/obs-module-load-notifications.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/services/obs-module-load-notifications.ts b/app/services/obs-module-load-notifications.ts index 2f5ed65ea37a..2bc162196e72 100644 --- a/app/services/obs-module-load-notifications.ts +++ b/app/services/obs-module-load-notifications.ts @@ -1,8 +1,7 @@ -import type { IObsModuleLoadFailure } from 'obs-studio-node'; import type * as ObsStudioNode from 'obs-studio-node'; import type { ISceneCollectionSchema } from 'services/scene-collections'; -export type { IObsModuleLoadFailure }; +export type IObsModuleLoadFailure = ObsStudioNode.IObsModuleLoadFailure; export const NDI_RUNTIME_VERSION_MISMATCH: typeof ObsStudioNode.NDI_RUNTIME_VERSION_MISMATCH = 'NDI_RUNTIME_VERSION_MISMATCH'; @@ -58,7 +57,9 @@ export function activeSceneCollectionHasNdiSources( ): boolean { if (!activeCollectionId) return false; - const activeCollection = sceneCollections.find(collection => collection.id === activeCollectionId); + const activeCollection = sceneCollections.find( + collection => collection.id === activeCollectionId, + ); return activeCollection?.sources.some(source => source.type === 'ndi_source') || false; }