UNPKG

desktop-audio-proxy

Version:

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

601 lines (594 loc) 21.8 kB
'use strict'; var express = require('express'); var cors = require('cors'); var axios = require('axios'); class AudioProxyClient { constructor(options) { this.options = { proxyUrl: options?.proxyUrl || 'http://localhost:3001', autoDetect: options?.autoDetect ?? true, fallbackToOriginal: options?.fallbackToOriginal ?? true, retryAttempts: options?.retryAttempts || 3, retryDelay: options?.retryDelay || 1000, }; this.environment = this.detectEnvironment(); } detectEnvironment() { if (typeof window === 'undefined') { return 'unknown'; } if (window.__TAURI__) { return 'tauri'; } if (window.electron || window.process?.versions?.electron) { return 'electron'; } return 'web'; } getEnvironment() { return this.environment; } async isProxyAvailable() { try { const response = await fetch(`${this.options.proxyUrl}/health`); return response.ok; } catch { return false; } } async canPlayUrl(url) { // Check if it's a local file or data URL if (!url.startsWith('http://') && !url.startsWith('https://')) { return { url, status: 200, headers: {}, canPlay: true, requiresProxy: false, }; } // In development or web environment, external URLs need proxy const isDevelopment = this.isDevelopmentEnvironment(); const needsProxy = this.environment === 'web' || isDevelopment; if (needsProxy) { const proxyAvailable = await this.isProxyAvailable(); return { url, status: proxyAvailable ? 200 : 0, headers: {}, canPlay: proxyAvailable, requiresProxy: true, }; } // In production desktop apps, can play directly return { url, status: 200, headers: {}, canPlay: true, requiresProxy: false, }; } async getPlayableUrl(originalUrl) { // Handle local files and data URLs if (!originalUrl.startsWith('http://') && !originalUrl.startsWith('https://')) { return this.handleLocalUrl(originalUrl); } // Check if we need proxy const streamInfo = await this.canPlayUrl(originalUrl); if (!streamInfo.requiresProxy) { return originalUrl; } // Try to use proxy with retries for (let attempt = 0; attempt < this.options.retryAttempts; attempt++) { try { const proxyAvailable = await this.isProxyAvailable(); if (proxyAvailable) { const proxiedUrl = `${this.options.proxyUrl}/proxy?url=${encodeURIComponent(originalUrl)}`; // Verify the proxy URL works const response = await fetch(proxiedUrl, { method: 'HEAD' }); if (response.ok) { return proxiedUrl; } } } catch (error) { console.warn(`[AudioProxyClient] Proxy attempt ${attempt + 1} failed:`, error); if (attempt < this.options.retryAttempts - 1) { await this.delay(this.options.retryDelay); } } } // Fallback to original URL if configured if (this.options.fallbackToOriginal) { console.warn('[AudioProxyClient] Falling back to original URL'); return originalUrl; } throw new Error('Failed to get playable URL: Proxy not available'); } handleLocalUrl(url) { // Handle Tauri convertFileSrc if (this.environment === 'tauri' && window.__TAURI__) { const cleanPath = url.replace('file://', ''); return window.__TAURI__.convertFileSrc(cleanPath); } // Handle Electron file protocol if (this.environment === 'electron') { if (url.startsWith('/') || url.match(/^[A-Za-z]:\\/)) { return `file://${url}`; } } return url; } isDevelopmentEnvironment() { if (typeof window === 'undefined') return false; const hostname = window.location?.hostname || ''; const port = window.location?.port || ''; return hostname === 'localhost' || hostname === '127.0.0.1' || port === '3000' || port === '8080' || port === '5173'; } delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } enableDebug() { if (typeof window !== 'undefined') { window.__AUDIO_PROXY_DEBUG__ = true; } } } // Convenience function function createAudioClient(options) { return new AudioProxyClient(options); } class AudioProxyServer { constructor(config) { this.config = { port: config?.port || 3001, host: config?.host || 'localhost', corsOrigins: config?.corsOrigins || '*', timeout: config?.timeout || 60000, maxRedirects: config?.maxRedirects || 20, userAgent: config?.userAgent || 'DesktopAudioProxy/1.0', enableLogging: config?.enableLogging ?? true, enableTranscoding: config?.enableTranscoding ?? false, cacheEnabled: config?.cacheEnabled ?? false, cacheTTL: config?.cacheTTL || 3600, }; this.app = express(); this.setupMiddleware(); this.setupRoutes(); } setupMiddleware() { // CORS configuration const corsOptions = { origin: this.config.corsOrigins, credentials: true, methods: ['GET', 'POST', 'OPTIONS', 'HEAD'], allowedHeaders: ['Content-Type', 'Range', 'Accept-Encoding'], exposedHeaders: ['Content-Length', 'Content-Range', 'Accept-Ranges'], }; this.app.use(cors(corsOptions)); this.app.use(express.json()); // Request logging if (this.config.enableLogging) { this.app.use((req, res, next) => { console.log(`[AudioProxy] ${req.method} ${req.path}`); next(); }); } } setupRoutes() { // Health check endpoint this.app.get('/health', (req, res) => { res.json({ status: 'ok', version: '1.0.0', uptime: process.uptime(), config: { port: this.config.port, enableTranscoding: this.config.enableTranscoding, cacheEnabled: this.config.cacheEnabled, }, }); }); // Stream info endpoint this.app.get('/info', async (req, res) => { const url = req.query.url; if (!url) { res.status(400).json({ error: 'URL parameter required' }); return; } try { const info = await this.getStreamInfo(url); res.json(info); } catch (error) { res.status(500).json({ error: error.message }); } }); // Main proxy endpoint this.app.get('/proxy', this.handleProxy.bind(this)); // Handle OPTIONS for preflight this.app.options('/proxy', cors()); } async handleProxy(req, res) { const url = req.query.url; if (!url) { res.status(400).json({ error: 'URL parameter required' }); return; } try { if (this.config.enableLogging) { console.log(`[AudioProxy] Proxying: ${url}`); } const config = { method: 'GET', url: url, responseType: 'stream', headers: { 'User-Agent': this.config.userAgent, 'Accept': 'audio/*, application/octet-stream', 'Accept-Encoding': 'identity', ...(req.headers.range ? { Range: req.headers.range } : {}), }, maxRedirects: this.config.maxRedirects, timeout: this.config.timeout, decompress: false, validateStatus: (status) => status >= 200 && status < 400, }; const response = await axios(config); // Set response headers const headers = { 'Content-Type': response.headers['content-type'] || 'audio/mpeg', 'Cache-Control': this.config.cacheEnabled ? `public, max-age=${this.config.cacheTTL}` : 'no-cache, no-store', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS, HEAD', 'Access-Control-Allow-Headers': 'Content-Type, Range, Accept-Encoding', 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges', }; // Forward important headers const forwardHeaders = [ 'content-length', 'content-range', 'accept-ranges', 'etag', 'last-modified', ]; forwardHeaders.forEach(header => { if (response.headers[header]) { headers[header.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join('-')] = response.headers[header]; } }); res.status(response.status).set(headers); // Stream the response const stream = response.data; stream.pipe(res); stream.on('error', (error) => { if (this.config.enableLogging) { console.error('[AudioProxy] Stream error:', error); } if (!res.headersSent) { res.status(500).json({ error: 'Stream error' }); } }); stream.on('end', () => { if (this.config.enableLogging) { console.log('[AudioProxy] Stream completed'); } }); } catch (error) { if (this.config.enableLogging) { console.error('[AudioProxy] Error:', error.message); } if (error.response) { res.status(error.response.status).json({ error: `Target server returned ${error.response.status}`, message: error.message, }); } else { res.status(500).json({ error: 'Proxy error', message: error.message, }); } } } async getStreamInfo(url) { try { const response = await axios.head(url, { headers: { 'User-Agent': this.config.userAgent, }, maxRedirects: this.config.maxRedirects, timeout: 10000, }); return { url: response.config.url || url, contentType: response.headers['content-type'], contentLength: parseInt(response.headers['content-length'] || '0'), status: response.status, headers: response.headers, canPlay: true, requiresProxy: true, }; } catch (error) { throw new Error(`Failed to get stream info: ${error.message}`); } } async start() { return new Promise((resolve, reject) => { try { this.server = this.app.listen(this.config.port, this.config.host, () => { console.log(`🎵 Desktop Audio Proxy running on http://${this.config.host}:${this.config.port}`); console.log(`📡 Use http://${this.config.host}:${this.config.port}/proxy?url=YOUR_AUDIO_URL`); resolve(); }); this.server.on('error', (error) => { if (error.code === 'EADDRINUSE') { console.error(`Port ${this.config.port} is already in use`); } reject(error); }); } catch (error) { reject(error); } }); } async stop() { return new Promise((resolve) => { if (this.server) { this.server.close(() => { console.log('🛑 Desktop Audio Proxy stopped'); resolve(); }); } else { resolve(); } }); } getInfo() { return { port: this.config.port, host: this.config.host, isRunning: !!this.server && this.server.listening, }; } } // Convenience functions function createProxyServer(config) { return new AudioProxyServer(config); } async function startProxyServer(config) { const server = createProxyServer(config); await server.start(); return server; } class TauriAudioService { constructor(config) { this.isTauri = typeof window !== 'undefined' && !!window.__TAURI__; if (this.isTauri) { this.tauriInvoke = window.__TAURI__.invoke; } this.client = new AudioProxyClient({ proxyUrl: config?.audioOptions?.proxyUrl || 'http://localhost:3001', autoDetect: config?.audioOptions?.autoDetect ?? true, fallbackToOriginal: config?.audioOptions?.fallbackToOriginal ?? true, retryAttempts: config?.audioOptions?.retryAttempts || 3, retryDelay: config?.audioOptions?.retryDelay || 1000, }); } async getStreamableUrl(originalUrl) { if (!this.isTauri) { // Not in Tauri, use standard client return this.client.getPlayableUrl(originalUrl); } // Check if it's already a Tauri protocol URL if (this.isTauriProtocolUrl(originalUrl)) { return originalUrl; } // Handle local files with convertFileSrc if (!originalUrl.startsWith('http://') && !originalUrl.startsWith('https://')) { return window.__TAURI__.convertFileSrc(originalUrl); } // For external URLs, check if we're in development const isDevelopment = this.isDevelopmentMode(); if (isDevelopment) { // In development, always use proxy for external URLs // This bypasses WebKit codec issues return this.client.getPlayableUrl(originalUrl); } // In production, try direct URL first try { // You could add a Tauri command to verify URL accessibility if (this.tauriInvoke) { const streamInfo = await this.tauriInvoke('check_audio_stream', { url: originalUrl }); if (streamInfo.status === 200) { return originalUrl; } } } catch (error) { console.warn('[TauriAudioService] Direct URL check failed:', error); } // Fall back to proxy if needed return this.client.getPlayableUrl(originalUrl); } async getStreamInfo(url) { if (this.tauriInvoke) { try { return await this.tauriInvoke('check_audio_stream', { url }); } catch (error) { console.error('[TauriAudioService] Failed to get stream info:', error); } } return this.client.canPlayUrl(url); } async preloadAudio(url) { // Implement audio preloading logic const playableUrl = await this.getStreamableUrl(url); if (typeof Audio !== 'undefined') { const audio = new Audio(); audio.preload = 'metadata'; audio.src = playableUrl; } } isTauriProtocolUrl(url) { return url.startsWith('asset://') || url.startsWith('tauri://') || url.startsWith('stream://') || url.includes('tauri://localhost'); } isDevelopmentMode() { return window.location?.hostname === 'localhost' || window.location?.hostname === '127.0.0.1'; } isInTauri() { return this.isTauri; } async checkDependencies() { if (this.tauriInvoke) { try { return await this.tauriInvoke('check_dependencies'); } catch (error) { console.error('[TauriAudioService] Failed to check dependencies:', error); } } return { ffmpeg: false, gstreamer: false, message: 'Unable to check dependencies', }; } } class ElectronAudioService { constructor(config) { this.isElectron = this.detectElectron(); if (this.isElectron && window.electronAPI) { this.electronAPI = window.electronAPI; } this.client = new AudioProxyClient({ proxyUrl: config?.audioOptions?.proxyUrl || 'http://localhost:3001', autoDetect: config?.audioOptions?.autoDetect ?? true, fallbackToOriginal: config?.audioOptions?.fallbackToOriginal ?? true, retryAttempts: config?.audioOptions?.retryAttempts || 3, retryDelay: config?.audioOptions?.retryDelay || 1000, }); } detectElectron() { if (typeof window === 'undefined') return false; return !!window.electron || !!window.process?.versions?.electron || !!window.electronAPI; } async getStreamableUrl(originalUrl) { if (!this.isElectron) { // Not in Electron, use standard client return this.client.getPlayableUrl(originalUrl); } // Handle local files if (!originalUrl.startsWith('http://') && !originalUrl.startsWith('https://')) { return this.handleLocalFile(originalUrl); } // For external URLs in Electron const isDevelopment = this.isDevelopmentMode(); // Electron generally has better codec support than WebKit // But may still need proxy for CORS in development if (isDevelopment) { const proxyAvailable = await this.client.isProxyAvailable(); if (proxyAvailable) { return this.client.getPlayableUrl(originalUrl); } } // In production or if proxy not available, try direct return originalUrl; } handleLocalFile(filePath) { // Convert to file:// protocol if needed if (filePath.startsWith('/') || filePath.match(/^[A-Za-z]:\\/)) { return `file://${filePath}`; } if (filePath.startsWith('file://')) { return filePath; } // If electron API provides file handling if (this.electronAPI?.getFileUrl) { return this.electronAPI.getFileUrl(filePath); } return filePath; } async getStreamInfo(url) { // If Electron provides stream checking if (this.electronAPI?.checkAudioStream) { try { return await this.electronAPI.checkAudioStream(url); } catch (error) { console.error('[ElectronAudioService] Failed to get stream info:', error); } } return this.client.canPlayUrl(url); } async enableProtocolInterception() { // This would be implemented in the main process if (this.electronAPI?.enableAudioProtocol) { await this.electronAPI.enableAudioProtocol(); } } isDevelopmentMode() { if (typeof window === 'undefined') return false; const isDev = process.env.NODE_ENV === 'development' || window.location?.hostname === 'localhost' || window.location?.hostname === '127.0.0.1'; return isDev; } isInElectron() { return this.isElectron; } async checkSystemCodecs() { const audio = new Audio(); const formats = [ { mime: 'audio/mpeg', name: 'MP3' }, { mime: 'audio/ogg', name: 'OGG' }, { mime: 'audio/wav', name: 'WAV' }, { mime: 'audio/webm', name: 'WebM' }, { mime: 'audio/aac', name: 'AAC' }, { mime: 'audio/flac', name: 'FLAC' }, ]; const supportedFormats = []; const missingCodecs = []; formats.forEach(format => { const canPlay = audio.canPlayType(format.mime); if (canPlay === 'probably' || canPlay === 'maybe') { supportedFormats.push(format.name); } else { missingCodecs.push(format.name); } }); return { supportedFormats, missingCodecs }; } } exports.AudioProxyClient = AudioProxyClient; exports.AudioProxyServer = AudioProxyServer; exports.ElectronAudioService = ElectronAudioService; exports.TauriAudioService = TauriAudioService; exports.createAudioClient = createAudioClient; exports.createProxyServer = createProxyServer; exports.startProxyServer = startProxyServer; //# sourceMappingURL=index.js.map