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
JavaScript
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