[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(azuracast): Add MVP azuracast source implementation

FoxxMD (Mar 11, 2025, 3:21 PM UTC) 81bde3a7 44ce0cba

+467 -34
+11
config/azuracast.json.example
··· 1 + [ 2 + { 3 + "type": "azuracast", 4 + "enable": true, 5 + "name": "azura", 6 + "data": { 7 + "url": "ws://192.168.0.101", 8 + "station": "my-station-name" 9 + } 10 + } 11 + ]
+3 -3
package-lock.json
··· 11746 11746 "peer": true 11747 11747 }, 11748 11748 "node_modules/ws": { 11749 - "version": "8.18.0", 11750 - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", 11751 - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", 11749 + "version": "8.18.1", 11750 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", 11751 + "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", 11752 11752 "engines": { 11753 11753 "node": ">=10.0.0" 11754 11754 },
+5 -2
src/backend/common/infrastructure/config/source/sources.ts
··· 1 + import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js"; 1 2 import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js"; 2 3 import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js"; 3 4 import { JellyApiSourceAIOConfig, JellyApiSourceConfig, JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js"; ··· 38 39 | ChromecastSourceConfig 39 40 | MusikcubeSourceConfig 40 41 | MPDSourceConfig 41 - | VLCSourceConfig; 42 + | VLCSourceConfig 43 + | AzuracastSourceConfig; 42 44 43 45 export type SourceAIOConfig = 44 46 SpotifySourceAIOConfig ··· 60 62 | ChromecastSourceAIOConfig 61 63 | MusikcubeSourceAIOConfig 62 64 | MPDSourceAIOConfig 63 - | VLCSourceAIOConfig; 65 + | VLCSourceAIOConfig 66 + | AzuracastSourceAIOConfig;
+85
src/backend/common/vendor/azuracast/AzuracastApiClient.ts
··· 1 + import { childLogger } from "@foxxmd/logging"; 2 + import { URLData } from "../../../../core/Atomic.js"; 3 + import { joinedUrl, normalizeWSAddress } from "../../../utils/NetworkUtils.js"; 4 + import { AbstractApiOptions } from "../../infrastructure/Atomic.js"; 5 + import { AzuracastData, AzuraStationResponse } from "../../infrastructure/config/source/azuracast.js"; 6 + import AbstractApiClient from "../AbstractApiClient.js"; 7 + import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket' 8 + 9 + 10 + export class AzuracastApiClient extends AbstractApiClient { 11 + 12 + declare config: AzuracastData 13 + 14 + urlData: URLData; 15 + 16 + wsNowPlaying: AzuraStationResponse 17 + wsCurrenTime: number = 0; 18 + socket!: WS; 19 + 20 + constructor(name: any, config: AzuracastData, options: AbstractApiOptions) { 21 + super('Azuracast API', name, config, options); 22 + 23 + this.urlData = normalizeWSAddress(config.url); 24 + } 25 + 26 + connectWS() { 27 + 28 + const url = joinedUrl(this.urlData.url, '/api/live/nowplaying/websocket'); 29 + const socket = new WebSocket(url); 30 + 31 + socket.onopen = (e) => { 32 + socket.send(JSON.stringify({ 33 + subs: { 34 + [`station:${this.config.station}`]: {"recover": true} 35 + } 36 + })); 37 + }; 38 + 39 + socket.onerror = (e) => { 40 + this.logger.error(e); 41 + } 42 + 43 + // Handle a now-playing event from a station. Update your now-playing data accordingly. 44 + function handleSseData(ssePayload, useTime = true) { 45 + const jsonData = ssePayload.data; 46 + 47 + if (useTime && 'current_time' in jsonData) { 48 + this.wsCurrenTime = jsonData.current_time; 49 + } 50 + 51 + this.wsNowPlaying = jsonData.np as AzuraStationResponse; 52 + } 53 + 54 + socket.onmessage = (e) => { 55 + 56 + const jsonData = JSON.parse(e.data as string); 57 + 58 + if ('connect' in jsonData) { 59 + const connectData = jsonData.connect; 60 + 61 + if ('data' in connectData) { 62 + // Legacy SSE data 63 + connectData.data.forEach( 64 + (initialRow) => handleSseData(initialRow) 65 + ); 66 + } else { 67 + // New Centrifugo time format 68 + if ('time' in connectData) { 69 + this.wsCurrenTime = Math.floor(connectData.time / 1000); 70 + } 71 + 72 + // New Centrifugo cached NowPlaying initial push. 73 + for (const subName in connectData.subs) { 74 + const sub = connectData.subs[subName]; 75 + if ('publications' in sub && sub.publications.length > 0) { 76 + sub.publications.forEach((initialRow) => handleSseData(initialRow, false)); 77 + } 78 + } 79 + } 80 + } else if ('pub' in jsonData) { 81 + handleSseData(jsonData.pub); 82 + } 83 + }; 84 + } 85 + }
+269
src/backend/sources/AzuracastSource.ts
··· 1 + import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 2 + import { sleep } from "../utils.js"; 3 + import { RecentlyPlayedOptions } from "./AbstractSource.js"; 4 + import { childLogger, Logger } from "@foxxmd/logging"; 5 + import { EventEmitter } from "events"; 6 + import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket' 7 + import pEvent from 'p-event'; 8 + import { PlayObject, URLData } from "../../core/Atomic.js"; 9 + import { UpstreamError } from "../common/errors/UpstreamError.js"; 10 + import { 11 + FormatPlayObjectOptions, 12 + InternalConfig, 13 + PlayerStateData, 14 + PlayPlatformId, 15 + REPORTED_PLAYER_STATUSES, 16 + SINGLE_USER_PLATFORM_ID, 17 + } from "../common/infrastructure/Atomic.js"; 18 + import { AzuracastSourceConfig, AzuraNowPlayingResponse, AzuraStationResponse } from "../common/infrastructure/config/source/azuracast.js"; 19 + import { isPortReachable, normalizeWSAddress } from "../utils/NetworkUtils.js"; 20 + import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; 21 + import { AzuracastPlayerState } from "./PlayerState/AzuracastPlayerState.js"; 22 + 23 + 24 + export class AzuracastSource extends MemoryPositionalSource { 25 + 26 + declare config: AzuracastSourceConfig; 27 + 28 + urlData!: URLData; 29 + 30 + wsNowPlaying: AzuraStationResponse 31 + wsCurrenTime: number = 0; 32 + client!: WS; 33 + 34 + 35 + constructor(name: any, config: AzuracastSourceConfig, internal: InternalConfig, emitter: EventEmitter) { 36 + const { 37 + data = {} 38 + } = config; 39 + const { 40 + ...rest 41 + } = data; 42 + super('azuracast', name, { ...config, data: { ...rest } }, internal, emitter); 43 + 44 + const { 45 + data: { 46 + url, 47 + } = {} 48 + } = config; 49 + this.requiresAuth = false; 50 + this.canPoll = true; 51 + } 52 + 53 + protected async doBuildInitData(): Promise<true | string | undefined> { 54 + const { 55 + data: { 56 + url 57 + } = {} 58 + } = this.config; 59 + if (url === null || url === undefined || url === '') { 60 + throw new Error('url must be defined'); 61 + } 62 + this.urlData = normalizeWSAddress(url, { defaultPath: '/api/live/nowplaying/websocket' }); 63 + const normal = this.urlData.normal; 64 + this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`) 65 + if (!normal.includes('ws://') && !normal.includes('wss://')) { 66 + throw new Error(`Server URL must be start with with ws:// or wss://`); 67 + } 68 + this.client = new WS(this.urlData.url.toString(), { 69 + automaticOpen: false, 70 + retry: { 71 + retries: 0 72 + } 73 + }); 74 + const wsLogger = childLogger(this.logger, 'WS'); 75 + this.client.addEventListener('retry', (e) => { 76 + wsLogger.verbose(`Retrying connection, attempt ${e.attempt}`, { labels: 'WS' }); 77 + }); 78 + this.client.addEventListener('close', (e) => { 79 + wsLogger.warn(`Connection was closed: ${e.code} => ${e.reason}`, { labels: 'WS' }); 80 + if (e.reason.includes('unauthenticated')) { 81 + this.authed = false; 82 + } 83 + }); 84 + this.client.addEventListener('open', (e) => { 85 + wsLogger.verbose(`Connection was established.`, { labels: 'WS' }); 86 + // if (this.authed) { 87 + // // was a reconnect, try auto authenticating 88 + // wsLogger.verbose('Resending auth message after (probably) reconnection...'); 89 + // this.client.send(JSON.stringify(this.getAuthPayload())); 90 + // } 91 + }); 92 + this.client.addEventListener('error', (e) => { 93 + if (e.message.includes('Connection failed after')) { 94 + this.connectionOK = false; 95 + //this.authed = false; 96 + } 97 + const hint = e.error?.cause?.message ?? undefined; 98 + wsLogger.error(new Error(`Communication with server failed${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error })); 99 + }); 100 + 101 + this.client.addEventListener('message', (e) => { 102 + this.parseWSData(getMessageData<any>(e)); 103 + // if (isAuthenticateResponse(data)) { 104 + // wsLogger.verbose(`${!data.options.authenticated ? 'NOT ' : ''}Authenticated for Muiskcube ${data.options.environment.app_version} with API v${data.options.environment.api_version}`); 105 + // } 106 + }); 107 + return true; 108 + } 109 + 110 + private parseWSPayload(payload: any, useTime = true) { 111 + const jsonData = payload.data; 112 + 113 + if (useTime && 'current_time' in jsonData) { 114 + this.wsCurrenTime = jsonData.current_time; 115 + } 116 + 117 + this.wsNowPlaying = jsonData.np as AzuraStationResponse; 118 + } 119 + 120 + private parseWSData(jsonData: any) { 121 + 122 + if ('connect' in jsonData) { 123 + const connectData = jsonData.connect; 124 + 125 + if ('data' in connectData) { 126 + // Legacy SSE data 127 + connectData.data.forEach( 128 + (initialRow) => this.parseWSPayload(initialRow) 129 + ); 130 + } else { 131 + // New Centrifugo time format 132 + if ('time' in connectData) { 133 + this.wsCurrenTime = Math.floor(connectData.time / 1000); 134 + } 135 + 136 + // New Centrifugo cached NowPlaying initial push. 137 + for (const subName in connectData.subs) { 138 + const sub = connectData.subs[subName]; 139 + if ('publications' in sub && sub.publications.length > 0) { 140 + sub.publications.forEach((initialRow) => this.parseWSPayload(initialRow, false)); 141 + } 142 + } 143 + } 144 + } else if ('pub' in jsonData) { 145 + this.parseWSPayload(jsonData.pub); 146 + } 147 + } 148 + 149 + protected async doCheckConnection(): Promise<true | string | undefined> { 150 + try { 151 + try { 152 + await isPortReachable(this.urlData.port, { host: this.urlData.url.hostname }); 153 + this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`); 154 + } catch (e) { 155 + throw e; 156 + } 157 + 158 + this.client.open(); 159 + const opened = await pEvent(this.client, 'open'); 160 + return true; 161 + } catch (e) { 162 + this.client.close(); 163 + const hint = e.error?.cause?.message ?? undefined; 164 + throw new Error(`Could not connect to Azuracast server${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error ?? e }); 165 + } 166 + } 167 + 168 + onPollPostAuthCheck = async (): Promise<boolean> => { 169 + this.logger.verbose(`Listening for activity on Station ${this.config.data.station}`); 170 + this.client.send(JSON.stringify({ 171 + subs: { 172 + [`station:${this.config.data.station}`]: { "recover": true } 173 + } 174 + })); 175 + return true; 176 + } 177 + 178 + // TODO return based on user intervention 179 + protected isStationValidListen = () => { 180 + if(this.wsNowPlaying === undefined) { 181 + this.logger.debug({leaf: `Station ${this.config.data.station}`}, `No data returned yet`); 182 + return false; 183 + } 184 + if(!this.wsNowPlaying.is_online && this.config.data.monitorWhenLive) { 185 + this.logger.debug({leaf: `Station ${this.config.data.station}`}, `Currently offline`); 186 + return false; 187 + } 188 + if(this.config.data.monitorWhenListeners !== undefined) { 189 + if(this.config.data.monitorWhenListeners === true && this.wsNowPlaying.listeners.current === 0) { 190 + this.logger.debug({leaf: `Station ${this.config.data.station}`}, `No listeners`); 191 + return false; 192 + } 193 + if(typeof this.config.data.monitorWhenListeners === 'number' && this.wsNowPlaying.listeners.current < this.config.data.monitorWhenListeners) { 194 + this.logger.debug({leaf: `Station ${this.config.data.station}`}, `Requries ${this.config.data.monitorWhenListeners} listeners to be active but currently only ${this.wsNowPlaying.listeners.current}`); 195 + return false; 196 + } 197 + } 198 + return true; 199 + } 200 + 201 + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { 202 + if (this.client.readyState !== this.client.OPEN) { 203 + throw new Error('WS connection is no longer open.'); 204 + } 205 + 206 + let play: PlayObject | undefined; 207 + const online = this.isStationValidListen(); 208 + 209 + if(this.isStationValidListen() && this.wsNowPlaying.now_playing !== undefined) { 210 + play = formatPlayObj(this.wsNowPlaying.now_playing); 211 + } 212 + 213 + const playerState: PlayerStateData = { 214 + platformId: SINGLE_USER_PLATFORM_ID, 215 + status: online ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.stopped, 216 + play, 217 + position: online && play !== undefined ? play.meta.trackProgressPosition : undefined 218 + } 219 + 220 + return this.processRecentPlays([playerState]); 221 + } 222 + 223 + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new AzuracastPlayerState(logger, id, opts); 224 + } 225 + 226 + const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOptions = {}): PlayObject => { 227 + 228 + const { 229 + song, 230 + duration, 231 + elapsed, 232 + remaining, 233 + } = obj; 234 + 235 + const { 236 + text, 237 + artist, 238 + title, 239 + album 240 + } = song; 241 + 242 + const track: string = title ?? text; 243 + 244 + return { 245 + data: { 246 + artists: artist !== undefined && artist !== '' ? [artist] : [], 247 + album: album !== '' ? album : undefined, 248 + track, 249 + duration 250 + }, 251 + meta: { 252 + trackProgressPosition: elapsed 253 + } 254 + } 255 + } 256 + 257 + const getMessageData = <T>(e: any): T => { 258 + return JSON.parse(e.data) as T; 259 + } 260 + 261 + const isCloseEvent = (e: Event): e is CloseEvent => { 262 + return e.type === 'close'; 263 + } 264 + const isErrorEvent = (e: Event): e is ErrorEvent => { 265 + return e.type === 'error'; 266 + } 267 + const isRetryEvent = (e: Event): e is RetryEvent => { 268 + return e.type === 'retry'; 269 + }
+7 -29
src/backend/sources/MusikcubeSource.ts
··· 5 5 import normalizeUrl from 'normalize-url'; 6 6 import pEvent from 'p-event'; 7 7 import { URL } from "url"; 8 - import { PlayObject } from "../../core/Atomic.js"; 8 + import { PlayObject, URLData } from "../../core/Atomic.js"; 9 9 import { UpstreamError } from "../common/errors/UpstreamError.js"; 10 10 import { 11 11 FormatPlayObjectOptions, ··· 23 23 import { sleep } from "../utils.js"; 24 24 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 25 25 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 26 + import { normalizeWSAddress } from "../utils/NetworkUtils.js"; 26 27 27 28 const CLIENT_STATE = { 28 29 0: 'connecting', ··· 34 35 export class MusikcubeSource extends MemoryPositionalSource { 35 36 declare config: MusikcubeSourceConfig; 36 37 37 - url: URL; 38 + url: URLData; 38 39 39 40 40 41 client!: WS; ··· 56 57 } = {} 57 58 } = config; 58 59 this.deviceId = device_id ?? name; 59 - this.url = MusikcubeSource.parseConnectionUrl(url); 60 + this.url = normalizeWSAddress(url, {defaultPort: 7905}); 60 61 this.requiresAuth = true; 61 62 this.canPoll = true; 62 63 } 63 64 64 - static parseConnectionUrl(valRaw: string) { 65 - let val = valRaw.trim(); 66 - if(!val.match(/^(?:wss?|https?):/i)) { 67 - val = `ws://${val}`; 68 - } 69 - const normal = normalizeUrl(val, {removeTrailingSlash: false}) 70 - const url = new URL(normal); 71 - 72 - // default WS 73 - if (url.protocol === 'https:') { 74 - url.protocol = 'wss:'; 75 - } else if (url.protocol === 'http:') { 76 - url.protocol = 'ws:'; 77 - } else { 78 - url.protocol = 'ws:' 79 - } 80 - 81 - if (url.port === null || url.port === '') { 82 - url.port = '7905'; 83 - } 84 - return url; 85 - } 86 - 87 65 protected async doBuildInitData(): Promise<true | string | undefined> { 88 66 const { 89 67 data: { 90 68 url 91 69 } = {} 92 70 } = this.config; 93 - const normal = this.url.toString(); 71 + const normal = this.url.normal; 94 72 this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`) 95 73 if (!normal.includes('ws://') && !normal.includes('wss://')) { 96 - throw new Error(`Server URL must be start with with ws:// or wss://`); 74 + throw new Error(`Server URL must start with ws:// or wss://`); 97 75 } 98 - this.client = new WS(this.url.toString(), { 76 + this.client = new WS(this.url.url.toString(), { 99 77 automaticOpen: false, 100 78 retry: { 101 79 retries: 0
+16
src/backend/sources/PlayerState/AzuracastPlayerState.ts
··· 1 + import { Logger } from "@foxxmd/logging"; 2 + import { PlayPlatformId, REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js"; 3 + import { AbstractPlayerState, PlayerStateOptions } from "./AbstractPlayerState.js"; 4 + import { GenericPlayerState } from "./GenericPlayerState.js"; 5 + import { PositionalPlayerState } from "./PositionalPlayerState.js"; 6 + 7 + export class AzuracastPlayerState extends PositionalPlayerState { 8 + constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) { 9 + super(logger, platformId, {allowedDrift: 17000, rtTruth: true, ...(opts || {})}); 10 + this.gracefulEndBuffer = this.allowedDrift / 1000; 11 + } 12 + 13 + protected isSessionStillPlaying(position: number): boolean { 14 + return this.reportedStatus === REPORTED_PLAYER_STATUSES.playing; 15 + } 16 + }
+27
src/backend/sources/ScrobbleSources.ts
··· 3 3 import EventEmitter from "events"; 4 4 import { ConfigMeta, InternalConfig, isSourceType, SourceType, sourceTypes } from "../common/infrastructure/Atomic.js"; 5 5 import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js"; 6 + import { AzuracastData, AzuracastSourceConfig } from "../common/infrastructure/config/source/azuracast.js"; 6 7 import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js"; 7 8 import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; 8 9 import { ··· 31 32 import { parseBool, readJson } from "../utils.js"; 32 33 import { validateJson } from "../utils/ValidationUtils.js"; 33 34 import AbstractSource from "./AbstractSource.js"; 35 + import { AzuracastSource } from "./AzuracastSource.js"; 34 36 import { ChromecastSource } from "./ChromecastSource.js"; 35 37 import DeezerSource from "./DeezerSource.js"; 36 38 import JellyfinApiSource from "./JellyfinApiSource.js"; ··· 168 170 break; 169 171 case 'vlc': 170 172 this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("VLCSourceConfig"); 173 + break; 174 + case 'azuracast': 175 + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("AzuracastSourceConfig"); 171 176 break; 172 177 } 173 178 } ··· 501 506 data: ytm as YTMusicData 502 507 }); 503 508 } 509 + break; 510 + case 'azuracast': 511 + const azura = { 512 + station: process.env.AZURA_STATION, 513 + url: process.env.AZURA_URL, 514 + monitorWhenListeners: process.env.AZURA_LISTENERS_NUM, 515 + monitorWhenLive: process.env.AZURA_LIVE, 516 + apiKey: process.env.AZURA_KEY 517 + } 518 + if (!Object.values(azura).every(x => x === undefined)) { 519 + configs.push({ 520 + type: 'azuracast', 521 + name: 'unnamed', 522 + source: 'ENV', 523 + mode: 'single', 524 + configureAs: defaultConfigureAs, 525 + data: azura as unknown as AzuracastData 526 + }); 527 + } 504 528 break; 505 529 default: 506 530 break; ··· 680 704 break; 681 705 case 'vlc': 682 706 newSource = await new VLCSource(name, compositeConfig as VLCSourceConfig, this.internalConfig, this.emitter); 707 + break; 708 + case 'azuracast': 709 + newSource = await new AzuracastSource(name, compositeConfig as AzuracastSourceConfig, this.internalConfig, this.emitter); 683 710 break; 684 711 default: 685 712 break;
+44
src/backend/utils/NetworkUtils.ts
··· 79 79 } 80 80 } 81 81 82 + export const normalizeWSAddress = (val: string, options: {defaultPort?: number | string, defaultPath?: string} = {}): URLData => { 83 + let cleanUserUrl = val.trim(); 84 + const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val); 85 + if (results !== undefined && results.groups && results.groups.length > 0) { 86 + cleanUserUrl = results.groups[0]; 87 + } 88 + if(!cleanUserUrl.match(/^(?:wss?|https?):/i)) { 89 + cleanUserUrl = `ws://${cleanUserUrl}`; 90 + } 91 + const normal = normalizeUrl(val, {removeTrailingSlash: false}) 92 + const url = new URL(normal); 93 + 94 + // default WS 95 + if (url.protocol === 'https:') { 96 + url.protocol = 'wss:'; 97 + } else if (url.protocol === 'http:') { 98 + url.protocol = 'ws:'; 99 + } else if(url.protocol === '') { 100 + url.protocol = 'ws:' 101 + } 102 + 103 + const {defaultPort, defaultPath} = options; 104 + 105 + let port: number; 106 + if(url.port === null || url.port === '') { 107 + if(defaultPort !== undefined) { 108 + url.port = defaultPort.toString(); 109 + port = parseInt(url.port); 110 + } else { 111 + port = url.protocol === 'ws:' ? 80 : 443; 112 + } 113 + } 114 + 115 + if(url.pathname === '/' && defaultPath !== undefined) { 116 + url.pathname = defaultPath; 117 + } 118 + 119 + return { 120 + url, 121 + normal: url.toString(), 122 + port 123 + } 124 + } 125 + 82 126 export const generateBaseURL = (userUrl: string | undefined, defaultPort: number | string): URL => { 83 127 const urlStr = userUrl ?? `http://localhost:${defaultPort}`; 84 128 let cleanUserUrl = urlStr.trim();