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