Compare commits

..
Author SHA1 Message Date
Your NameandClaude Opus 4.5 d5c96880d7 chore: Bump version to 2.3.3-beta+25
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 10:10:43 +00:00
Your NameandClaude Opus 4.5 540b95f558 Add MA version detection for Sendspin proxy support
- Add _parseServerVersion() helper for version comparisons
- Add _serverHasSendspinProxy() to detect MA 2.8.0+ (has /sendspin proxy)
- Add _logSendspinTroubleshooting() with version-specific guidance
- Update connection logic to try local proxy fallback for local IPs
- Improve logging to show server version and proxy availability
- MA 2.7.x users now get clear guidance: upgrade to 2.8.0+ or configure proxy

Fixes #17

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 21:50:54 +00:00
Your Name a1c84f29c1 fix: Fix followRedirects usage and add missing painting import 2025-12-21 21:00:02 +00:00
Your Name 2cf3135f18 fix(ci): Add flutter gen-l10n step to build workflow 2025-12-21 20:46:21 +00:00
Your NameandClaude Opus 4.5 1b33c57cb7 fix: Improve local IP and Sendspin connection handling
Fixes issue #17 - users unable to connect via local IP or see local player

Changes:
1. **Local IP connection handling**:
   - Don't follow HTTP->HTTPS redirects for local IPs
   - Detect 308/301/302 redirects and handle gracefully
   - Prevents certificate verification errors when server redirects to HTTPS

2. **Sendspin connection logic**:
   - Remove non-existent API calls (sendspin/connection_info, webrtc_offer, etc.)
   - For local IPs: Connect directly to ws://ip:8927/sendspin
   - For external/HTTPS: Use proxy at wss://server/sendspin with auth
   - Better error messages explaining what to configure

3. **Utility improvements**:
   - Add _isLocalNetworkHost() helper for IP detection
   - Include .ts.net (Tailscale) as local network

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 20:35:52 +00:00
7 changed files with 258 additions and 148 deletions
+3
View File
@@ -41,6 +41,9 @@ jobs:
- name: Generate code (Drift database)
run: dart run build_runner build --delete-conflicting-outputs
- name: Generate localizations
run: flutter gen-l10n
- name: Generate launcher icons
run: flutter pub run flutter_launcher_icons
+144 -41
View File
@@ -529,24 +529,35 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Check if server version is >= 2.7.0b20 (uses Sendspin instead of builtin_player)
bool _serverUsesSendspin() {
/// Parse server version into components for version comparisons
/// Returns null if version cannot be parsed
({int major, int minor, int patch, int? beta})? _parseServerVersion() {
final serverInfo = _api?.serverInfo;
if (serverInfo == null) return false;
if (serverInfo == null) return null;
final versionStr = serverInfo['server_version'] as String?;
if (versionStr == null) return false;
if (versionStr == null) return null;
// Parse version like "2.8.0b2" or "2.7.0b20" or "2.7.1"
// Format: MAJOR.MINOR.PATCH[bBETA]
final versionRegex = RegExp(r'^(\d+)\.(\d+)\.(\d+)(?:b(\d+))?');
final match = versionRegex.firstMatch(versionStr);
if (match == null) return false;
if (match == null) return null;
final major = int.parse(match.group(1)!);
final minor = int.parse(match.group(2)!);
final patch = int.parse(match.group(3)!);
final beta = match.group(4) != null ? int.parse(match.group(4)!) : null;
return (
major: int.parse(match.group(1)!),
minor: int.parse(match.group(2)!),
patch: int.parse(match.group(3)!),
beta: match.group(4) != null ? int.parse(match.group(4)!) : null,
);
}
/// Check if server version is >= 2.7.0b20 (uses Sendspin instead of builtin_player)
bool _serverUsesSendspin() {
final version = _parseServerVersion();
if (version == null) return false;
final (:major, :minor, :patch, :beta) = version;
// Compare with 2.7.0b20
if (major > 2) return true;
@@ -562,6 +573,32 @@ class MusicAssistantProvider with ChangeNotifier {
return beta >= 20;
}
/// Check if server version is >= 2.8.0 (has built-in /sendspin proxy endpoint)
/// The /sendspin proxy was added in MA 2.8.0 (PR #2840)
/// MA 2.7.x does NOT have this proxy - users must expose port 8927 directly
/// or manually configure reverse proxy routing to port 8927
bool _serverHasSendspinProxy() {
final version = _parseServerVersion();
if (version == null) return false;
final (:major, :minor, :patch, :beta) = version;
// Compare with 2.8.0
if (major > 2) return true;
if (major < 2) return false;
// major == 2
if (minor > 8) return true;
if (minor < 8) return false;
// minor == 8, any 2.8.x version has the proxy
return true;
}
/// Get the server version string for logging
String _getServerVersionString() {
final serverInfo = _api?.serverInfo;
return serverInfo?['server_version'] as String? ?? 'unknown';
}
Future<void> _registerLocalPlayer() async {
if (_api == null) return;
@@ -665,19 +702,24 @@ class MusicAssistantProvider with ChangeNotifier {
/// Connect to Music Assistant via Sendspin protocol (MA 2.7.0b20+)
/// This is the replacement for builtin_player when that API is not available.
///
/// Connection strategy:
/// 1. If server is HTTPS, try external wss://{server}/sendspin first
/// 2. Get local_ws_url from API and try that
/// 3. WebRTC fallback as last resort (requires TURN servers)
/// Connection strategy depends on MA version and network:
/// - MA 2.8.0+: Has built-in /sendspin proxy, works with any reverse proxy setup
/// - MA 2.7.x: NO proxy, must either:
/// - Use local IP with port 8927 exposed, OR
/// - Manually configure reverse proxy to route /sendspin to port 8927
Future<bool> _connectViaSendspin() async {
if (_api == null || _serverUrl == null) return false;
try {
final serverVersion = _getServerVersionString();
final hasProxy = _serverHasSendspinProxy();
_logger.log('Sendspin: Server version $serverVersion, has proxy: $hasProxy');
// Initialize Sendspin service
_sendspinService?.dispose();
_sendspinService = SendspinService(_serverUrl!);
// Set auth token for MA 2.7.1+ proxy authentication
// Set auth token for proxy authentication (MA 2.8.0+ or manually configured proxy)
final authToken = await SettingsService.getMaAuthToken();
if (authToken != null) {
_sendspinService!.setAuthToken(authToken);
@@ -706,42 +748,59 @@ class MusicAssistantProvider with ChangeNotifier {
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('Sendspin: Player ID: $playerId');
// Strategy 1: If server is HTTPS, try external wss:// first
final isHttps = _serverUrl!.startsWith('https://') ||
(!_serverUrl!.contains('://') && !_serverUrl!.contains(':'));
// Parse server URL to determine connection strategy
final serverUri = Uri.parse(_serverUrl!.startsWith('http')
? _serverUrl!
: 'https://$_serverUrl');
final isLocalIp = _isLocalNetworkHost(serverUri.host);
final isHttps = serverUri.scheme == 'https' ||
(!_serverUrl!.contains('://') && !isLocalIp);
// Strategy 1: For local IPs, connect directly to Sendspin port 8927
if (isLocalIp) {
final localSendspinUrl = 'ws://${serverUri.host}:8927/sendspin';
_logger.log('Sendspin: Local network detected, trying direct connection: $localSendspinUrl');
final connected = await _sendspinService!.connectWithUrl(localSendspinUrl);
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local network (port 8927)');
return true;
}
_logger.log('⚠️ Sendspin: Local connection to port 8927 failed');
// For local IPs, also try the proxy path in case user has a local reverse proxy
_logger.log('Sendspin: Trying local proxy fallback...');
final localProxyUrl = 'ws://${serverUri.host}:${serverUri.hasPort ? serverUri.port : 8095}/sendspin';
final proxyConnected = await _sendspinService!.connectWithUrl(localProxyUrl);
if (proxyConnected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local proxy');
return true;
}
}
// Strategy 2: For external/HTTPS servers, use the proxy at /sendspin
if (isHttps || !isLocalIp) {
if (hasProxy) {
_logger.log('Sendspin: MA 2.8.0+ detected, using built-in /sendspin proxy');
} else {
_logger.log('Sendspin: MA 2.7.x detected, trying /sendspin (requires manual proxy config)');
}
if (isHttps) {
_logger.log('Sendspin: Server is HTTPS, trying external connection first');
final connected = await _sendspinService!.connect();
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via external URL');
_logger.log('✅ Sendspin: Connected via external proxy');
return true;
}
_logger.log('⚠️ Sendspin: External connection failed, trying local URL');
_logger.log('⚠️ Sendspin: External proxy connection failed');
}
// Strategy 2: Get local_ws_url from API and try that
final connectionInfo = await _api!.getSendspinConnectionInfo(playerId);
if (connectionInfo != null) {
final localWsUrl = connectionInfo['local_ws_url'] as String?;
if (localWsUrl != null) {
_logger.log('Sendspin: Trying local WebSocket URL: $localWsUrl');
final connected = await _sendspinService!.connectWithUrl(localWsUrl);
if (connected) {
_sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via local URL');
return true;
}
}
}
// Strategy 3: WebRTC fallback (requires TURN servers from Nabucasa)
// For now, just log that we'd need WebRTC - implementing full WebRTC
// would require additional dependencies (flutter_webrtc)
_logger.log('⚠️ Sendspin: WebRTC fallback not yet implemented');
_logger.log('ℹ️ For external access, configure /sendspin route in your reverse proxy');
// All strategies failed - provide version-specific guidance
_logger.log('❌ Sendspin: All connection strategies failed');
_logSendspinTroubleshooting(isLocalIp, hasProxy, serverVersion);
return false;
} catch (e) {
@@ -750,6 +809,29 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Log troubleshooting guidance based on setup
void _logSendspinTroubleshooting(bool isLocalIp, bool hasProxy, String serverVersion) {
if (isLocalIp) {
_logger.log('ℹ️ LOCAL IP SETUP: Add port 8927 to your Docker compose:');
_logger.log(' ports:');
_logger.log(' - "8095:8095"');
_logger.log(' - "8927:8927" # Required for Sendspin');
} else if (!hasProxy) {
// MA 2.7.x without built-in proxy
_logger.log('ℹ️ MA $serverVersion does not have built-in /sendspin proxy');
_logger.log('ℹ️ OPTIONS:');
_logger.log(' 1. Upgrade to Music Assistant 2.8.0+ (recommended)');
_logger.log(' 2. Or add reverse proxy config for /sendspin → port 8927');
_logger.log(' Traefik: PathPrefix(`/sendspin`) → service port 8927');
_logger.log(' Nginx: location /sendspin { proxy_pass http://ma:8927; }');
} else {
// MA 2.8.0+ but still failing
_logger.log('ℹ️ MA $serverVersion should have /sendspin proxy');
_logger.log('ℹ️ Check that your reverse proxy forwards WebSocket connections');
_logger.log(' Ensure Upgrade and Connection headers are passed through');
}
}
/// Handle Sendspin play command
void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) async {
_logger.log('🎵 Sendspin: Play command received');
@@ -2972,4 +3054,25 @@ class MusicAssistantProvider with ChangeNotifier {
_api?.dispose();
super.dispose();
}
// ============================================================================
// UTILITY METHODS
// ============================================================================
/// Check if a hostname is a local/private network address
bool _isLocalNetworkHost(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net'); // Tailscale - treat as local since it's a VPN
}
}
+1
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/music_assistant_api.dart';
+97 -19
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import 'no_auth_strategy.dart';
@@ -86,6 +87,22 @@ class AuthManager {
return baseUrl;
}
/// Check if a hostname is a local/private IP address
bool _isLocalIpAddress(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local');
}
/// Try to detect auth strategy for a given URL
/// Returns the strategy if successful, null if connection failed
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
@@ -187,15 +204,50 @@ class AuthManager {
_logger.log('Checking MA API at: $apiUrl');
// Try to get server info - this tells us if auth is required
// Note: The command is just 'info', not 'server/info'
final response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
// Use IOClient to control redirect behavior for local IPs
final isLocalIp = _isLocalIpAddress(uri.host);
http.Response response;
if (isLocalIp) {
// For local IPs, don't follow redirects - handle them explicitly
final client = HttpClient();
try {
final request = await client.postUrl(apiUrl);
request.followRedirects = false;
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'command': 'info'}));
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
// Check for redirect to HTTPS
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
final location = ioResponse.headers.value('location');
_logger.log('Local IP redirects to: $location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
// Don't follow the redirect - it will likely fail with cert error
// Instead, return null to trigger fallback logic
client.close();
return null;
}
}
final body = await ioResponse.transform(utf8.decoder).join();
response = http.Response(body, ioResponse.statusCode);
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.post which follows redirects
response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
}
_logger.log('MA API response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}');
@@ -255,21 +307,47 @@ class AuthManager {
/// Test if server is accessible without authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
try {
// Test root endpoint - Music Assistant doesn't have /api/info
final testUrl = serverUrl;
_logger.log('No-auth test URL: $testUrl');
final uri = Uri.parse(serverUrl);
_logger.log('No-auth test URL: $serverUrl');
final response = await http.get(
Uri.parse(testUrl),
// Don't follow redirects - we want to see if server requires auth
).timeout(const Duration(seconds: 10));
final isLocalIp = _isLocalIpAddress(uri.host);
int statusCode;
_logger.log('No-auth status: ${response.statusCode}');
if (isLocalIp) {
// For local IPs, don't follow redirects to avoid HTTPS cert issues
final client = HttpClient();
try {
final request = await client.getUrl(uri);
request.followRedirects = false;
final response = await request.close().timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
// Check for redirect to HTTPS
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
final location = response.headers.value('location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
client.close();
return false; // Can't connect - would need HTTPS which has cert issues
}
}
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.get
final response = await http.get(uri).timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
}
_logger.log('No-auth status: $statusCode');
// 200 = no auth required
// 302/307 = redirect (probably to auth)
// 302/307/308 = redirect (probably to auth or HTTPS)
// 401 = auth required
if (response.statusCode == 200) {
if (statusCode == 200) {
return true;
}
+9 -83
View File
@@ -2302,89 +2302,15 @@ class MusicAssistantAPI {
return _eventStreams['sendspin_player']!.stream;
}
/// Get Sendspin connection info for a player
/// Returns connection details including:
/// - local_ws_url: Direct WebSocket URL (e.g., ws://192.168.4.120:8927/sendspin)
/// - ice_servers: List of ICE/TURN servers for WebRTC fallback
/// - player_id: The player's ID
Future<Map<String, dynamic>?> getSendspinConnectionInfo(String playerId) async {
try {
_logger.log('Sendspin: Getting connection info for player $playerId');
final response = await _sendCommand(
'sendspin/connection_info',
args: {
'player_id': playerId,
},
);
final result = response['result'] as Map<String, dynamic>?;
if (result != null) {
_logger.log('Sendspin: Got connection info - local_ws_url: ${result['local_ws_url']}');
_logger.log('Sendspin: ICE servers count: ${(result['ice_servers'] as List?)?.length ?? 0}');
}
return result;
} catch (e) {
_logger.log('Sendspin: Error getting connection info: $e');
return null;
}
}
/// Send WebRTC offer to Sendspin server
/// Used when establishing WebRTC connection as fallback
Future<Map<String, dynamic>?> sendspinOffer(String playerId, String sdp) async {
try {
_logger.log('Sendspin: Sending WebRTC offer for player $playerId');
final response = await _sendCommand(
'sendspin/webrtc_offer',
args: {
'player_id': playerId,
'sdp': sdp,
},
);
return response['result'] as Map<String, dynamic>?;
} catch (e) {
_logger.log('Sendspin: Error sending WebRTC offer: $e');
return null;
}
}
/// Send WebRTC answer to Sendspin server
Future<void> sendspinAnswer(String playerId, String sdp) async {
try {
_logger.log('Sendspin: Sending WebRTC answer for player $playerId');
await _sendCommand(
'sendspin/webrtc_answer',
args: {
'player_id': playerId,
'sdp': sdp,
},
);
} catch (e) {
_logger.log('Sendspin: Error sending WebRTC answer: $e');
rethrow;
}
}
/// Send ICE candidate to Sendspin server
Future<void> sendspinIceCandidate(String playerId, Map<String, dynamic> candidate) async {
try {
await _sendCommand(
'sendspin/ice_candidate',
args: {
'player_id': playerId,
'candidate': candidate,
},
);
} catch (e) {
_logger.log('Sendspin: Error sending ICE candidate: $e');
// Don't rethrow - ICE candidate failures are common and non-fatal
}
}
// NOTE: The following Sendspin API methods were removed because they don't exist in MA:
// - getSendspinConnectionInfo (sendspin/connection_info)
// - sendspinOffer (sendspin/webrtc_offer)
// - sendspinAnswer (sendspin/webrtc_answer)
// - sendspinIceCandidate (sendspin/ice_candidate)
//
// Sendspin connection is handled directly via WebSocket to:
// - Local: ws://{server-ip}:8927/sendspin
// - External: wss://{server}/sendspin (via MA's proxy with auth)
/// Update Sendspin player state
/// Similar to updateBuiltinPlayerState but for Sendspin protocol
+3 -4
View File
@@ -120,12 +120,11 @@ class SendspinService {
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
}
// Strategy 2: If external failed or server is HTTP, try local connection
// Note: We would need local_ws_url from getSendspinConnectionInfo API
// For now, skip this step and fall back to WebRTC (handled by provider)
// Note: Local network connections (ws://ip:8927/sendspin) are handled
// by the provider calling connectWithUrl() directly, not by this method.
if (!connected) {
_logger.log('Sendspin: External connection failed, WebRTC fallback needed');
_logger.log('Sendspin: External proxy connection failed');
_updateState(SendspinConnectionState.error);
return false;
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.3.2-beta+24
version: 2.3.3-beta+25
environment:
sdk: '>=3.0.0 <4.0.0'