diff --git a/src/backend/sources/ChromecastSource.ts b/src/backend/sources/ChromecastSource.ts index 992fb7dd..9a2d4861 100644 --- a/src/backend/sources/ChromecastSource.ts +++ b/src/backend/sources/ChromecastSource.ts @@ -154,7 +154,7 @@ export class ChromecastSource extends MemorySource { await discoveryAvahi('_googlecast._tcp', { logger: this.logger, sanity: initial, - onDiscover: (service, raw) => { + onDiscover: (service) => { this.initializeDevice(service); }, }); @@ -168,7 +168,7 @@ export class ChromecastSource extends MemorySource { await discoveryNative('_googlecast._tcp', { logger: this.logger, sanity: initial, - onDiscover: (service, raw) => { + onDiscover: (service) => { this.initializeDevice(service); }, }); diff --git a/src/backend/utils/MDNSUtils.ts b/src/backend/utils/MDNSUtils.ts index 7c37d224..f00f0d6e 100644 --- a/src/backend/utils/MDNSUtils.ts +++ b/src/backend/utils/MDNSUtils.ts @@ -5,6 +5,7 @@ import { sleep } from "../utils.js"; import {ErrorWithCause} from "pony-cause"; import { MdnsDeviceInfo } from "../common/infrastructure/Atomic.js"; import {Browser, Service, ServiceType} from "@astronautlabs/mdns"; +import {debounce, DebouncedFunction} from "./debounce.js"; export interface AvahiService { service_name: string @@ -19,7 +20,7 @@ export interface AvahiService { export interface DiscoveryOptions { sanity?: boolean - onDiscover?: (service: MdnsDeviceInfo, raw: T) => void + onDiscover?: (service: MdnsDeviceInfo) => void onDnsError?: (err: Error) => void duration?: number, logger?: Logger @@ -38,17 +39,42 @@ export const discoveryAvahi = async (service: string, options?: DiscoveryOptions maybeLogger.debug(`Starting mDNS discovery with Avahi => Listening for ${(duration / 1000).toFixed(2)}s`); let anyDiscovered = false; + let services = new Map(); + + const triggerDiscovery = () => { + for(const [k,v] of services.entries()) { + maybeLogger.debug(`Discovered device "${v.name}" with ${v.addresses.length} interfaces - first host seen: ${v.addresses[0]}`); + onDiscover(v); + services.delete(k); + } + } + + let debouncedFunc: DebouncedFunction; + try { const browser = new AvahiBrowser(service); browser.on(AvahiBrowser.EVENT_SERVICE_UP, async (service: AvahiService) => { anyDiscovered = true; - maybeLogger.debug(`Discovered device "${service.service_name}" at ${service.target.host}`); - if (onDiscover !== undefined) { - onDiscover({ - name: service.service_name, - addresses: [service.target.host], - type: service.target.service_type - }, service) + + if(onDiscover !== undefined) { + + let foundService = services.get(service.service_name); + if(foundService === undefined) { + foundService = { + name: service.service_name, + addresses: [service.target.host], + type: service.target.service_type, + } + } else { + foundService.addresses.push(service.target.host); + } + services.set(foundService.name, foundService); + + if(debouncedFunc === undefined) { + debouncedFunc = debounce(() => triggerDiscovery(), 1000); + } else { + await debouncedFunc(); + } } }); browser.on(AvahiBrowser.EVENT_DNSSD_ERROR, (err) => { @@ -109,9 +135,9 @@ export const discoveryNative = async (service: string, options?: DiscoveryOption const browser = new Browser(service, {resolve: true}) .on('serviceUp', async (service) => { - maybeLogger.debug(`Discovered device "${service.name}" at ${service.addresses?.[0]}`); + maybeLogger.debug(`Discovered device "${service.name}" with ${service.addresses.length} interfaces -- first host seen: ${service.addresses?.[0]}`); if (onDiscover) { - onDiscover({name: service.name, addresses: service.addresses, type: service.service_type}, service); + onDiscover({name: service.name, addresses: service.addresses, type: service.service_type}); } }) browser.on('error', (err) => { diff --git a/src/backend/utils/debounce.ts b/src/backend/utils/debounce.ts new file mode 100644 index 00000000..92d4c3a9 --- /dev/null +++ b/src/backend/utils/debounce.ts @@ -0,0 +1,70 @@ +// ES6 Async version of the "classic" JavaScript Debounce function. +// Works both with and without promises, so you can replace your existing +// debounce helper function with this one (and it will behave the same). +// The only difference is that this one returns a promise, so you can use +// it with async/await. +// +// I've converted this into a TypeScript module, and added a few more +// features to it, such as the ability to cancel the debounce, and also +// execute the function immediately, using the `doImmediately` method. +// +// Returns a function, that, as long as it continues to be invoked, will not +// be triggered. The function will be called after it stops being called for +// N milliseconds. If `immediate` is passed, trigger the function on the +// leading edge, instead of the trailing. +// +// @author: @carlhannes +// @param {Function} func - The function to debounce. +// @param {Number} wait - The number of milliseconds to delay. +// @param {Boolean} immediate - Whether to execute the function at the beginning. +// @returns {Function} - The debounced function. +// @example +// import debounce from 'utils/debounce'; +// +// const debounced = debounce(() => { +// console.log('Hello world!'); +// }, 1000); +// +// debounced(); +// +// https://gist.github.com/carlhannes/4b318c28e95f635191bffb656b9a2cfe +export interface DebounceConstructor { + (func: () => void, wait: number, immediate?: boolean): DebouncedFunction; +} + +export interface DebouncedFunction { + (...args: unknown[]): Promise; + cancel(): void; + doImmediately(...args: unknown[]): Promise; +} + +export const debounce: DebounceConstructor = (func: () => void, wait: number, immediate?: boolean) => { + let timeout: NodeJS.Timeout | null = null; + const debouncedFn: DebouncedFunction = (...args) => new Promise((resolve) => { + clearTimeout(timeout); + timeout = setTimeout(() => { + timeout = null; + if (!immediate) { + void Promise.resolve(func.apply(this, [...args])).then(resolve); + } + }, wait); + if (immediate && !timeout) { + void Promise.resolve(func.apply(this, [...args])).then(resolve); + } + }); + + debouncedFn.cancel = () => { + clearTimeout(timeout); + timeout = null; + }; + + debouncedFn.doImmediately = (...args) => new Promise((resolve) => { + clearTimeout(timeout); + timeout = setTimeout(() => { + timeout = null; + void Promise.resolve(func.apply(this, [...args])).then(resolve); + }, 0); + }); + + return debouncedFn; +};