UNPKG

desktop-audio-proxy

Version:

A comprehensive audio streaming solution for Tauri and Electron apps that bypasses CORS and WebKit codec issues

806 lines (800 loc) 33.4 kB
import express from 'express'; import cors from 'cors'; import axios from 'axios'; import { createServer } from 'net'; class AudioProxyClient { constructor(options = {}) { this.options = { proxyUrl: options.proxyUrl || 'http://localhost:3002', autoDetect: options.autoDetect ?? true, fallbackToOriginal: options.fallbackToOriginal ?? true, retryAttempts: options.retryAttempts || 3, retryDelay: options.retryDelay || 1000, proxyConfig: options.proxyConfig || {}, }; this.environment = this.detectEnvironment(); } detectEnvironment() { if (typeof window === 'undefined') { return 'unknown'; } if (window.__TAURI__) { return 'tauri'; } if (window.electronAPI || (typeof process !== 'undefined' && process?.versions && process.versions.electron)) { return 'electron'; } return 'web'; } getEnvironment() { return this.environment; } async isProxyAvailable() { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); const response = await fetch(`${this.options.proxyUrl}/health`, { signal: controller.signal, method: 'GET', cache: 'no-cache', }); clearTimeout(timeoutId); if (response.ok) { const data = await response.json(); console.log('[AudioProxyClient] Proxy server available:', data); return true; } return false; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.warn('[AudioProxyClient] Proxy server unavailable:', errorMessage); return false; } } async canPlayUrl(url) { console.log('[AudioProxyClient] Processing URL:', url); // Check if it's a local file if (this.isLocalFile(url)) { console.log('[AudioProxyClient] Using local file handler'); return { url, status: 200, headers: {}, canPlay: true, requiresProxy: false, }; } // Check if proxy is available const proxyAvailable = await this.isProxyAvailable(); if (proxyAvailable) { try { const infoUrl = `${this.options.proxyUrl}/info?url=${encodeURIComponent(url)}`; const response = await fetch(infoUrl); if (response.ok) { const data = await response.json(); const streamInfo = { url: data.url, status: data.status, headers: data.headers || {}, canPlay: true, requiresProxy: true, contentType: data.contentType, contentLength: data.contentLength, acceptRanges: data.acceptRanges, lastModified: data.lastModified, }; console.log('[AudioProxyClient] Stream info:', streamInfo); return streamInfo; } } catch (error) { console.warn('[AudioProxyClient] Failed to get stream info via proxy:', error); } } // Fallback: assume it needs proxy const streamInfo = { url, status: 0, headers: {}, canPlay: false, requiresProxy: true, }; console.log('[AudioProxyClient] Stream info:', streamInfo); return streamInfo; } async getPlayableUrl(url) { console.log('[AudioProxyClient] Processing URL:', url); // Handle local files if (this.isLocalFile(url)) { console.log('[AudioProxyClient] Using local file handler'); return this.handleLocalFile(url); } // Check stream info const streamInfo = await this.canPlayUrl(url); if (streamInfo.requiresProxy) { console.log('[AudioProxyClient] Proxy required, checking availability...'); // Try proxy with retries for (let attempt = 1; attempt <= this.options.retryAttempts; attempt++) { const proxyAvailable = await this.isProxyAvailable(); if (proxyAvailable) { console.log('[AudioProxyClient] Generated proxy URL:', `${this.options.proxyUrl}/proxy?url=${encodeURIComponent(url)}`); return `${this.options.proxyUrl}/proxy?url=${encodeURIComponent(url)}`; } if (attempt < this.options.retryAttempts) { console.log(`[AudioProxyClient] Proxy not available on attempt ${attempt}`); await this.delay(this.options.retryDelay); } } // Proxy failed, fallback if enabled if (this.options.fallbackToOriginal) { console.log('[AudioProxyClient] Falling back to original URL (may have CORS issues)'); return url; } else { throw new Error('Proxy server unavailable and fallback disabled'); } } return url; } isLocalFile(url) { return (url.startsWith('/') || url.startsWith('./') || url.startsWith('../') || url.startsWith('file://') || url.startsWith('blob:') || url.startsWith('data:') || !!url.match(/^[a-zA-Z]:\\/)); // Windows path } handleLocalFile(url) { // Handle data: and blob: URLs directly - no conversion needed if (url.startsWith('data:') || url.startsWith('blob:')) { return url; } // In Tauri, use convertFileSrc for file:// URLs if (this.environment === 'tauri' && window.__TAURI__) { try { const { convertFileSrc } = window.__TAURI__.tauri; if (url.startsWith('file://') || url.startsWith('/') || url.match(/^[a-zA-Z]:\\/)) { return convertFileSrc(url); } } catch (error) { console.warn('[AudioProxyClient] Failed to convert file source with Tauri:', error); // Fallback to original URL if conversion fails } } // For other environments or fallback, return as-is return url; } delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } } function createAudioClient(options) { return new AudioProxyClient(options); } // Type declarations for Tauri API that extends client.ts declarations class TauriAudioService { constructor(options = {}) { // Use audioOptions if provided, otherwise use the options directly const clientOptions = options.audioOptions || options; this.audioClient = new AudioProxyClient(clientOptions); } async getStreamableUrl(url) { return await this.audioClient.getPlayableUrl(url); } async canPlayStream(url) { return await this.audioClient.canPlayUrl(url); } getEnvironment() { return this.audioClient.getEnvironment(); } async isProxyAvailable() { return await this.audioClient.isProxyAvailable(); } async checkSystemCodecs() { const audio = new Audio(); const formats = [ { name: 'MP3', mime: 'audio/mpeg', codecs: ['mp3'] }, { name: 'OGG', mime: 'audio/ogg', codecs: ['vorbis', 'opus'] }, { name: 'WAV', mime: 'audio/wav', codecs: ['pcm'] }, { name: 'AAC', mime: 'audio/aac', codecs: ['mp4a.40.2'] }, { name: 'FLAC', mime: 'audio/flac', codecs: ['flac'] }, { name: 'WEBM', mime: 'audio/webm', codecs: ['vorbis', 'opus'] }, { name: 'M4A', mime: 'audio/mp4', codecs: ['mp4a.40.2'] }, ]; const supportedFormats = []; const missingCodecs = []; const capabilities = {}; for (const format of formats) { let bestSupport = ''; let isSupported = false; // Test basic MIME type const basicSupport = audio.canPlayType(format.mime); capabilities[format.name + '_basic'] = basicSupport; if (basicSupport === 'probably' || basicSupport === 'maybe') { bestSupport = basicSupport; isSupported = true; } // Test with codecs for (const codec of format.codecs) { const codecSupport = audio.canPlayType(`${format.mime}; codecs="${codec}"`); capabilities[format.name + '_' + codec] = codecSupport; if (codecSupport === 'probably') { bestSupport = 'probably'; isSupported = true; } else if (codecSupport === 'maybe' && bestSupport !== 'probably') { bestSupport = 'maybe'; isSupported = true; } } capabilities[format.name] = bestSupport; if (isSupported) { supportedFormats.push(format.name); } else { missingCodecs.push(format.name); } } // Check for additional Tauri-specific audio capabilities if available if (this.getEnvironment() === 'tauri' && window.__TAURI__?.tauri?.invoke) { try { const { invoke } = window.__TAURI__.tauri; // Try to get system audio info from Tauri backend const systemAudioInfo = await invoke('get_system_audio_info').catch(() => null); if (systemAudioInfo && typeof systemAudioInfo === 'object') { capabilities['tauri_system_info'] = JSON.stringify(systemAudioInfo); // Enhanced format support based on system capabilities const audioInfo = systemAudioInfo; if (audioInfo.supportedFormats) { audioInfo.supportedFormats.forEach((format) => { if (!supportedFormats.includes(format)) { supportedFormats.push(format); // Remove from missing codecs if it was there const missingIndex = missingCodecs.indexOf(format); if (missingIndex > -1) { missingCodecs.splice(missingIndex, 1); } } }); } } } catch (error) { console.warn('[TauriAudioService] Could not access Tauri backend for codec detection:', error); } } return { supportedFormats, missingCodecs, capabilities }; } // Tauri-specific method to get audio file metadata async getAudioMetadata(filePath) { if (this.getEnvironment() !== 'tauri' || !window.__TAURI__?.tauri?.invoke) { return null; } try { const { invoke } = window.__TAURI__.tauri; const result = await invoke('get_audio_metadata', { path: filePath }); return result; } catch (error) { console.warn('[TauriAudioService] Failed to get audio metadata:', error); return null; } } // Tauri-specific method to enumerate audio devices async getAudioDevices() { if (this.getEnvironment() !== 'tauri' || !window.__TAURI__?.tauri?.invoke) { return null; } try { const { invoke } = window.__TAURI__.tauri; const result = await invoke('get_audio_devices'); return result; } catch (error) { console.warn('[TauriAudioService] Failed to get audio devices:', error); return null; } } } // Extend the existing Window interface from client.ts - don't redeclare electronAPI class ElectronAudioService { constructor(options = {}) { // Use audioOptions if provided, otherwise use the options directly const clientOptions = options.audioOptions || options; this.audioClient = new AudioProxyClient(clientOptions); } async getStreamableUrl(url) { return await this.audioClient.getPlayableUrl(url); } async canPlayStream(url) { return await this.audioClient.canPlayUrl(url); } getEnvironment() { return this.audioClient.getEnvironment(); } async isProxyAvailable() { return await this.audioClient.isProxyAvailable(); } async checkSystemCodecs() { const audio = new Audio(); const formats = [ { name: 'MP3', mime: 'audio/mpeg', codecs: ['mp3'] }, { name: 'OGG', mime: 'audio/ogg', codecs: ['vorbis', 'opus'] }, { name: 'WAV', mime: 'audio/wav', codecs: ['pcm'] }, { name: 'AAC', mime: 'audio/aac', codecs: ['mp4a.40.2'] }, { name: 'FLAC', mime: 'audio/flac', codecs: ['flac'] }, { name: 'WEBM', mime: 'audio/webm', codecs: ['vorbis', 'opus'] }, { name: 'M4A', mime: 'audio/mp4', codecs: ['mp4a.40.2'] }, ]; const supportedFormats = []; const missingCodecs = []; const capabilities = {}; for (const format of formats) { let bestSupport = ''; let isSupported = false; // Test basic MIME type const basicSupport = audio.canPlayType(format.mime); capabilities[format.name + '_basic'] = basicSupport; if (basicSupport === 'probably' || basicSupport === 'maybe') { bestSupport = basicSupport; isSupported = true; } // Test with codecs for (const codec of format.codecs) { const codecSupport = audio.canPlayType(`${format.mime}; codecs="${codec}"`); capabilities[format.name + '_' + codec] = codecSupport; if (codecSupport === 'probably') { bestSupport = 'probably'; isSupported = true; } else if (codecSupport === 'maybe' && bestSupport !== 'probably') { bestSupport = 'maybe'; isSupported = true; } } capabilities[format.name] = bestSupport; if (isSupported) { supportedFormats.push(format.name); } else { missingCodecs.push(format.name); } } const result = { supportedFormats, missingCodecs, capabilities }; // Add Electron version info if available if (this.getEnvironment() === 'electron') { try { if (typeof process !== 'undefined' && process.versions) { result.electronVersion = process.versions.electron; result.chromiumVersion = process.versions.chrome; } // Integrate with Electron main process for system codec detection const electronAPI = window.electronAPI; if (electronAPI?.getSystemAudioInfo) { try { const systemAudioInfo = await electronAPI.getSystemAudioInfo(); if (systemAudioInfo) { capabilities['electron_system_info'] = JSON.stringify(systemAudioInfo); // Enhanced format support based on system capabilities if (systemAudioInfo.supportedFormats) { systemAudioInfo.supportedFormats.forEach((format) => { if (!supportedFormats.includes(format)) { supportedFormats.push(format); // Remove from missing codecs if it was there const missingIndex = missingCodecs.indexOf(format); if (missingIndex > -1) { missingCodecs.splice(missingIndex, 1); } } }); } } } catch (error) { console.warn('[ElectronAudioService] Failed to get system audio info via IPC:', error); } } } catch (error) { console.warn('[ElectronAudioService] Could not access Electron version info:', error); } } return result; } // Electron-specific method to get audio file metadata via main process async getAudioMetadata(filePath) { const electronAPI = window.electronAPI; if (this.getEnvironment() !== 'electron' || !electronAPI?.getAudioMetadata) { return null; } try { return await electronAPI.getAudioMetadata(filePath); } catch (error) { console.warn('[ElectronAudioService] Failed to get audio metadata:', error); return null; } } // Electron-specific method to enumerate audio devices via main process async getAudioDevices() { const electronAPI = window.electronAPI; if (this.getEnvironment() !== 'electron' || !electronAPI?.getAudioDevices) { return null; } try { return await electronAPI.getAudioDevices(); } catch (error) { console.warn('[ElectronAudioService] Failed to get audio devices:', error); return null; } } // Electron-specific method to get system audio settings async getSystemAudioSettings() { const electronAPI = window.electronAPI; if (this.getEnvironment() !== 'electron' || !electronAPI?.getSystemAudioSettings) { return null; } try { return await electronAPI.getSystemAudioSettings(); } catch (error) { console.warn('[ElectronAudioService] Failed to get system audio settings:', error); return null; } } } // Utility function to check if a port is available async function isPortAvailable(port, host = 'localhost') { return new Promise(resolve => { const server = createServer(); server.listen(port, host, () => { server.close(() => { resolve(true); }); }); server.on('error', () => { resolve(false); }); }); } // Find the next available port starting from the given port async function findAvailablePort(startPort, host = 'localhost', maxAttempts = 10) { for (let i = 0; i < maxAttempts; i++) { const port = startPort + i; const available = await isPortAvailable(port, host); if (available) { return port; } } throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`); } class AudioProxyServer { constructor(config = {}) { this.server = null; this.actualPort = 0; this.config = { port: config.port || 3002, host: config.host || 'localhost', corsOrigins: config.corsOrigins || '*', timeout: config.timeout || 60000, maxRedirects: config.maxRedirects || 10, userAgent: config.userAgent || 'AudioProxy/1.0', enableLogging: config.enableLogging ?? true, enableTranscoding: config.enableTranscoding ?? false, cacheEnabled: config.cacheEnabled ?? true, cacheTTL: config.cacheTTL || 3600, }; this.app = express(); this.setupMiddleware(); this.setupRoutes(); } setupMiddleware() { // CORS middleware this.app.use(cors({ origin: this.config.corsOrigins, credentials: true, exposedHeaders: ['Content-Length', 'Content-Range', 'Accept-Ranges'], methods: ['GET', 'OPTIONS', 'HEAD'], allowedHeaders: ['Content-Type', 'Range', 'Accept-Encoding'], })); // Logging middleware if (this.config.enableLogging) { this.app.use((req, res, next) => { console.log(`[AudioProxy] ${req.method} ${req.path}`); next(); }); } } setupRoutes() { // Handle CORS preflight for all routes this.app.options('*', (req, res) => { res.set({ 'Access-Control-Allow-Origin': this.config.corsOrigins, 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Range, Accept-Encoding, User-Agent', 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Max-Age': '86400', // 24 hours }); res.status(204).end(); }); // Health check endpoint this.app.get('/health', (req, res) => { res.json({ status: 'ok', version: '1.1.1', uptime: process.uptime(), config: { port: this.actualPort || this.config.port, configuredPort: this.config.port, enableTranscoding: this.config.enableTranscoding, cacheEnabled: this.config.cacheEnabled, }, }); }); // Info endpoint this.app.get('/info', async (req, res) => { const url = req.query.url; if (!url) { return res.status(400).json({ error: 'URL parameter required' }); } try { // Get stream info without downloading const response = await axios({ method: 'HEAD', url: url, headers: { 'User-Agent': this.config.userAgent, Accept: 'audio/*,*/*;q=0.1', }, timeout: this.config.timeout, maxRedirects: this.config.maxRedirects, validateStatus: status => status < 400, }); res.json({ url, status: response.status, headers: response.headers, contentType: response.headers['content-type'], contentLength: response.headers['content-length'], acceptRanges: response.headers['accept-ranges'], lastModified: response.headers['last-modified'], }); } catch (error) { console.error('[AudioProxy] Info error:', error); if (error && typeof error === 'object' && 'response' in error) { const axiosError = error; if (axiosError.response) { res.status(axiosError.response.status).json({ error: `Upstream error: ${axiosError.response.status} ${axiosError.response.statusText}`, url: url, }); } } else { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: 'Failed to get stream info', message: errorMessage, url: url, }); } } }); // Proxy endpoint this.app.get('/proxy', async (req, res) => { const url = req.query.url; if (!url) { return res.status(400).json({ error: 'URL parameter required' }); } try { // Set CORS headers immediately res.set({ 'Access-Control-Allow-Origin': this.config.corsOrigins, 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges', 'Access-Control-Allow-Methods': 'GET, OPTIONS, HEAD', 'Access-Control-Allow-Headers': 'Content-Type, Range, Accept-Encoding', }); // Prepare request headers const requestHeaders = { 'User-Agent': this.config.userAgent, Accept: req.headers.accept || 'audio/*,*/*;q=0.1', 'Accept-Language': req.headers['accept-language'] || 'en-US,en;q=0.9', 'Cache-Control': 'no-cache', Pragma: 'no-cache', }; // Handle range requests for seeking support if (req.headers.range) { requestHeaders['Range'] = req.headers.range; } // Handle encoding if (req.headers['accept-encoding']) { requestHeaders['Accept-Encoding'] = req.headers['accept-encoding']; } // Use axios for better stream handling const response = await axios({ method: 'GET', url: url, headers: requestHeaders, responseType: 'stream', timeout: this.config.timeout, maxRedirects: this.config.maxRedirects, validateStatus: status => status < 400, // Accept redirects and success codes }); // Set response status res.status(response.status); // Copy relevant headers from the original response const headersToProxy = [ 'content-type', 'content-length', 'content-range', 'accept-ranges', 'cache-control', 'expires', 'last-modified', 'etag', ]; headersToProxy.forEach(header => { const value = response.headers[header]; if (value) { res.set(header, value); } }); // Handle errors during streaming const stream = response.data; stream.on('error', error => { console.error('[AudioProxy] Stream error:', error); if (!res.headersSent) { res.status(500).json({ error: 'Stream error', message: error.message, }); } else { res.end(); } }); res.on('close', () => { // Clean up stream if client disconnects if (stream && !stream.destroyed) { stream.destroy(); } }); res.on('error', error => { console.error('[AudioProxy] Response error:', error); if (stream && !stream.destroyed) { stream.destroy(); } }); // Pipe the stream to response stream.pipe(res); } catch (error) { console.error('[AudioProxy] Proxy error:', error); if (!res.headersSent) { if (error && typeof error === 'object' && 'response' in error) { const axiosError = error; if (axiosError.response) { // HTTP error from upstream res.status(axiosError.response.status).json({ error: `Upstream error: ${axiosError.response.status} ${axiosError.response.statusText}`, url: url, }); } } else if (error && typeof error === 'object' && 'code' in error) { const nodeError = error; if (nodeError.code === 'ENOTFOUND') { // DNS resolution failed res.status(404).json({ error: 'Audio source not found', message: 'Unable to resolve hostname', url: url, }); } else if (nodeError.code === 'ECONNREFUSED') { // Connection refused res.status(503).json({ error: 'Audio source unavailable', message: 'Connection refused', url: url, }); } else if (nodeError.code === 'ETIMEDOUT') { // Request timeout res.status(408).json({ error: 'Request timeout', message: 'Audio source did not respond in time', url: url, }); } else { // Generic error with code const errorMessage = nodeError.message || 'Unknown error'; res.status(500).json({ error: 'Proxy request failed', message: errorMessage, url: url, }); } } else { // Generic error const errorMessage = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: 'Proxy request failed', message: errorMessage, url: url, }); } } } }); } async start() { try { // Find an available port starting from the configured port this.actualPort = await findAvailablePort(this.config.port, this.config.host); return new Promise((resolve, reject) => { this.server = this.app.listen(this.actualPort, this.config.host, () => { if (this.actualPort !== this.config.port) { console.log(`⚠️ Port ${this.config.port} was occupied, using port ${this.actualPort} instead`); } console.log(`Desktop Audio Proxy running on http://${this.config.host}:${this.actualPort}`); console.log(`Use http://${this.config.host}:${this.actualPort}/proxy?url=YOUR_AUDIO_URL`); resolve(); }); this.server.on('error', (error) => { reject(error); }); }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to start proxy server: ${errorMessage}`); } } async stop() { return new Promise(resolve => { if (this.server) { this.server.close(() => { console.log('Desktop Audio Proxy stopped'); resolve(); }); } else { resolve(); } }); } getActualPort() { return this.actualPort || this.config.port; } getProxyUrl() { return `http://${this.config.host}:${this.getActualPort()}`; } } // Convenience functions function createProxyServer(config) { return new AudioProxyServer(config); } async function startProxyServer(config) { const server = createProxyServer(config); await server.start(); return server; } export { AudioProxyClient, AudioProxyServer, ElectronAudioService, TauriAudioService, createAudioClient, createProxyServer, startProxyServer }; //# sourceMappingURL=server.esm.js.map