Something went wrong. Try again.
[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
Something went wrong. Try again.
12 kB · 391 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391import type {Logger} from "@foxxmd/logging";import { parseRegexSingle } from "@foxxmd/regex-buddy-core";import address from "address";import net from 'node:net';import normalizeUrl from "normalize-url";import { join as joinPath } from "path";import { isDebugMode} from "../utils.ts";import type {URLData} from "../../core/Atomic.ts";import type { CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'import { WEBSOCKET_CLOSE_CODE_REASONS } from "../common/infrastructure/Atomic.ts";import { loggerNoop } from "../common/MaybeLogger.ts";import { formatBytes } from "./DataUtils.ts";
export interface PortReachableOpts { host: string, timeout?: number}/** * Copied from https://github.com/sindresorhus/is-port-reachable with error reporting * */export const isPortReachable = async (port: number, opts: PortReachableOpts) => { const {host, timeout = 1000} = opts;
const promise = new Promise(((resolve, reject) => { const socket = new net.Socket();
const onError = (e) => { socket.destroy(); reject(e); }; const onTimeout = () => { socket.destroy(); reject(new Error(`Connection timed out after ${timeout}ms`)); }
socket.setTimeout(timeout); socket.once('error', onError); socket.once('timeout', onTimeout);
socket.connect(port, host, () => { socket.end(); resolve(true); }); }));
try { await promise; return true; } catch (e) { throw e; }}
/** Test if a host:port is reachable via TCP * * Need to use net.connect instead of new.Socket() because the popular mocking libraries don't mock Socket * * https://github.com/gr2m/node-net-interceptor/issues/2 * https://github.com/moll/node-mitm/issues/42 * */export const isPortReachableConnect = async (port: number, opts: PortReachableOpts) => { const {host, timeout = 1000} = opts;
const promise = new Promise(((resolve, reject) => { const client = net.connect({ timeout, port, host }, () => { client.end(); resolve(true); });
client.on('error', (err) => { client.destroy(); reject(err); }); client.on('timeout', () => { reject(new Error(`Connection timed out after ${timeout}ms`)); }); }));
try { await promise; return true; } catch (e) { throw e; }}
const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/);const DOMAIN_AND_PORT: RegExp = new RegExp(/^([^:]+):(\d+)$/);const commonProtocols = ['http','https','ws','wss'];
export const normalizeWebAddress = (val: string, options: {defaultPath?: string, removeTrailingSlash?: boolean} = {}): URLData => { let cleanUserUrl = val.trim(); const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val); if (results !== undefined && results.groups && results.groups.length > 0) { cleanUserUrl = results.groups[0]; }
const {defaultPath, removeTrailingSlash = true} = options;
let normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash}); if(normal === cleanUserUrl) { // checking to see if input was DOMAIN:PORT // in which case we also check DOMAIN isn't mistakenly a protocol // and if it isn't then we force a protocol based on port // so that we get a full URL out of this function const res = parseRegexSingle(DOMAIN_AND_PORT, cleanUserUrl); if(res !== undefined && !commonProtocols.includes(res.groups[0])) { const protocol = Number.parseInt(res.groups[1]) === 443 ? 'https:' : 'http:'; cleanUserUrl = `${protocol}//${cleanUserUrl}`; normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash}); } } const u = new URL(normal); let port: number;
if (u.port === '') { port = u.protocol === 'https:' ? 443 : 80; } else { port = parseInt(u.port); // if user val does not include protocol and port is 443 then auto set to https if(port === 443 && !val.includes('http')) { if(u.protocol === 'http:') { u.protocol = 'https:'; } normal = normal.replace('http:', 'https:'); } }
if(u.pathname === '/' && defaultPath !== undefined) { u.pathname = defaultPath; normal = normalizeUrl(u.toString()); }
return { url: u, normal, port, input: val }}
export const normalizeWSAddress = (val: string, options: {defaultPort?: number | string, defaultPath?: string} = {}): URLData => { let cleanUserUrl = val.trim(); const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val); if (results !== undefined && results.groups && results.groups.length > 0) { cleanUserUrl = results.groups[0]; } if(!cleanUserUrl.match(/^(?:wss?|https?):/i)) { cleanUserUrl = `ws://${cleanUserUrl}`; } const normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash: false}) const url = new URL(normal);
// default WS if (url.protocol === 'https:') { url.protocol = 'wss:'; } else if (url.protocol === 'http:') { url.protocol = 'ws:'; } else if(url.protocol === '') { url.protocol = 'ws:' }
const {defaultPort, defaultPath} = options;
let port: number; if(url.port === null || url.port === '') { if(defaultPort !== undefined) { url.port = defaultPort.toString(); port = parseInt(url.port); } else { port = url.protocol === 'ws:' ? 80 : 443; } }
if(url.pathname === '/' && defaultPath !== undefined) { url.pathname = defaultPath; } return { url, normal: url.toString(), port, input: val }}
export const generateBaseURL = (userUrl: string | undefined, defaultPort: number | string): URL => { // handle scenario where passed value is an empty string *before* handing to normalizeUrl // since this throws an error let trueUserUrl: string | undefined = undefined; if(userUrl !== undefined) { const trimmed = userUrl.trim(); if(trimmed !== '' && trimmed !== '""' && trimmed !== "''") { trueUserUrl = trimmed; } } const urlStr = trueUserUrl ?? `http://localhost:${defaultPort}`; let cleanUserUrl = urlStr.trim(); const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, cleanUserUrl); if (results !== undefined && results.groups && results.groups.length > 0) { cleanUserUrl = results.groups[0]; } const base = normalizeUrl(cleanUserUrl, {removeSingleSlash: true}); const u = new URL(base); if (u.port === '') { if (u.protocol === 'https:') { u.port = '443'; } else if (trueUserUrl.includes(`${u.hostname}:80`)) { u.port = '80'; } else { u.port = defaultPort.toString(); } } return u;}export const joinedUrl = (url: URL, ...paths: string[]): URL => { // https://github.com/jfromaniello/url-join#in-nodejs const finalUrl = new URL(url); finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== ''))); return finalUrl;}
export const getBaseFromUrl = (url: URL): URL => new URL(`${url.protocol}//${url.host}`);
export const getAddress = (host = '0.0.0.0', logger?: Logger): { v4?: string, v6?: string, host: string } => { const local = host === '0.0.0.0' || host === '::' ? 'localhost' : host; let v4: string, v6: string; try { v4 = address.ip(); v6 = address.ipv6(); } catch (e) { if (isDebugMode()) { if (logger !== undefined) { logger.warn(new Error('Could not get machine IP address', {cause: e})); } else { console.warn('Could not get machine IP address'); console.warn(e); } } } return { host: local, v4, v6 };}const IPV4_REGEX = new RegExp(/^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/);export const isIPv4 = (address: string): boolean => { return parseRegexSingle(IPV4_REGEX, address) !== undefined;}
export const formatWebsocketClose = (e: CloseEvent): string => { const closeParts = []; let code = `${e.code}`; const codeHint = WEBSOCKET_CLOSE_CODE_REASONS[e.code]; if(codeHint !== undefined) { code = `(${e.code}) ${codeHint}`; } closeParts.push(code); if(e.reason !== undefined) { closeParts.push(e.reason); } return closeParts.join(' => ');}
export const isCloseEvent = (e: Event): e is CloseEvent => { return e.type === 'close';}export const isErrorEvent = (e: Event): e is ErrorEvent => { return e.type === 'error';}export const isRetryEvent = (e: Event): e is RetryEvent => { return e.type === 'retry';}
export const wsReadyStateToStr = (state: number): string => { switch(state) { case 0: return 'connecting'; case 1: return 'open'; case 2: return 'closing'; case 3: return 'closed'; default: return state.toString(); }}
export type StreamBodyOpts = { logger?: Logger, chunkDefaultSize?: number, fileHint?: string, headers?: Headers}
export const streamBodyProgress = async (stream: ReadableStream<Uint8Array<ArrayBufferLike>>, opts: StreamBodyOpts = {}) => { const { logger = loggerNoop, chunkDefaultSize = 1024 * 1024 * 10, // default to every 10MB, when we don't know response size fileHint = 'file', headers } = opts; let loading = true, chunks: any[] = []; const reader = stream.getReader();
let length: number, chunkReportSize: number = chunkDefaultSize, lastReportedSize: number = 0; if(headers !== undefined && null !== headers.get('content-length')) { length = +headers.get('content-length'); const [summary, size, unit] = formatBytes(length); if(unit === 'MiB' && size > 10) { switch(true) { case(size > 50): chunkReportSize = length/10; break; case(size > 20): chunkReportSize = length/5; break; default: chunkReportSize = length/3; break; } } logger.trace(`Downloading ${summary} ${fileHint}...`); } else { logger.trace(`Downloading ${fileHint} of unknown size (no content-length header)...`); }
let received = 0;
// Loop through the response stream and extract data chunks while (loading) { const { done, value } = await reader.read(); if (done) { // Finish loading loading = false; } else { // Push values to the chunk array chunks.push(value);
received += value.length; lastReportedSize += value.length; if(lastReportedSize >= chunkReportSize) { logger.trace(`Downloaded ${formatBytes(received)[0]}...`); lastReportedSize = 0; } } } logger.trace(`Finished download!`);
// Concat the chunks into a single array const body = new Uint8Array(received); let position = 0;
// Order the chunks by their respective position for (const chunk of chunks) { body.set(chunk, position); position += chunk.length; }
return body;}
/** * Converts a rate limit expressed as "N requests per M seconds" into * an equivalent maximum requests-per-1-second value, as a float. * * @param maxRequests - Maximum number of requests allowed in the window * @param seconds - Length of the window, in seconds * @returns Maximum number of requests allowed per 1 second (float) */export const maxRequestsPerSecond = (maxRequests: number, seconds: number): number => { if (!Number.isFinite(maxRequests) || maxRequests < 0) { throw new Error(`maxRequests must be a non-negative finite number, got ${maxRequests}`); } if (!Number.isFinite(seconds) || seconds <= 0) { throw new Error(`seconds must be a positive finite number, got ${seconds}`); }
return maxRequests / seconds;}