162 lines
6.0 KiB
C#
162 lines
6.0 KiB
C#
using System.Net;
|
|
using System.Net.NetworkInformation;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using System.Xml;
|
|
using System.Xml.Linq;
|
|
using System.Xml.XPath;
|
|
|
|
namespace MusicCast.Net.Client;
|
|
|
|
public partial class MusicCastDiscoveryService
|
|
{
|
|
private static readonly byte[] MulticastPacket = """
|
|
M-SEARCH * HTTP/1.1
|
|
HOST: 239.255.255.250:1900
|
|
MAN: "ssdp:discover"
|
|
ST: urn:schemas-upnp-org:device:MediaRenderer:1
|
|
MX: 1
|
|
|
|
|
|
"""u8.ToArray();
|
|
|
|
private static readonly HttpClient _httpClient = new();
|
|
|
|
private const string LocationUrlGroupName = "url";
|
|
[GeneratedRegex(@$"Location:\s(?<{LocationUrlGroupName}>http[s]?:\/\/.+\.xml)", RegexOptions.IgnoreCase)]
|
|
private static partial Regex LocationRegex();
|
|
|
|
/// <summary>
|
|
/// Get the ip address associated with the current local network.
|
|
/// If multiple networks cards are installed it only returns the first one.
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
public IPAddress? GetLocalSubnetAddress()
|
|
{
|
|
// Get a list of all network interfaces (usually one per network card, dialup, and VPN connection)
|
|
var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces();
|
|
|
|
foreach (NetworkInterface network in networkInterfaces)
|
|
{
|
|
// Read the IP configuration for each network
|
|
IPInterfaceProperties properties = network.GetIPProperties();
|
|
|
|
if (network.NetworkInterfaceType is NetworkInterfaceType.Ethernet or NetworkInterfaceType.Wireless80211 &&
|
|
network.OperationalStatus == OperationalStatus.Up &&
|
|
!network.Description.Contains("virtual", StringComparison.OrdinalIgnoreCase) &&
|
|
!network.Description.Contains("pseudo", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Each network interface may have multiple IP addresses
|
|
foreach (UnicastIPAddressInformation address in properties.UnicastAddresses)
|
|
{
|
|
// We're only interested in IPv4 addresses for now
|
|
if (address.Address.AddressFamily != AddressFamily.InterNetwork)
|
|
continue;
|
|
|
|
// Ignore loopback addresses (e.g., 127.0.0.1)
|
|
if (IPAddress.IsLoopback(address.Address))
|
|
continue;
|
|
|
|
return address.Address;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public Task<HashSet<string>> GetMusicCastDeviceAddresses(IPAddress bindAddress)
|
|
=> GetMusicCastDeviceAddresses(bindAddress, TimeSpan.FromSeconds(2));
|
|
|
|
/// <summary>
|
|
/// Scan for MusicCast devices on the network associated with the bind address. Only return devices that support
|
|
/// the YamahaExtendedControl v1 api
|
|
/// </summary>
|
|
/// <param name="bindAddress"></param>
|
|
/// <param name="scanDuration">Duration of the scan</param>
|
|
/// <returns></returns>
|
|
public async Task<HashSet<string>> GetMusicCastDeviceAddresses(IPAddress bindAddress, TimeSpan scanDuration)
|
|
{
|
|
using var client = new UdpClient(AddressFamily.InterNetwork);
|
|
|
|
client.Client.Bind(new IPEndPoint(bindAddress, 0));
|
|
client.JoinMulticastGroup(IPAddress.Parse("239.255.255.250"));
|
|
|
|
await client.SendAsync(MulticastPacket, "239.255.255.250", 1900);
|
|
await client.SendAsync(MulticastPacket, "239.255.255.250", 1900);
|
|
await client.SendAsync(MulticastPacket, "239.255.255.250", 1900);
|
|
|
|
var musicCastDevices = new HashSet<string>();
|
|
|
|
// Start scanning
|
|
var cancellationToken = new CancellationTokenSource(scanDuration).Token;
|
|
|
|
try
|
|
{
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
var received = await client.ReceiveAsync(cancellationToken);
|
|
var message = Encoding.UTF8.GetString(received.Buffer);
|
|
|
|
// Try to find the location of the DLNA description file
|
|
var match = LocationRegex().Match(message);
|
|
|
|
if (!match.Success || !match.Groups.TryGetValue(LocationUrlGroupName, out var group))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var location = group.Value;
|
|
|
|
var apiUrl = await GetApiUrlFromDlnaDescriptionFile(location, cancellationToken);
|
|
|
|
if (apiUrl is not null)
|
|
{
|
|
musicCastDevices.Add(apiUrl);
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Normal flow after scan duration
|
|
}
|
|
|
|
return musicCastDevices;
|
|
}
|
|
|
|
private static async Task<string?> GetApiUrlFromDlnaDescriptionFile(string location, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
var response = await _httpClient.GetAsync(location, cancellationToken);
|
|
await using var descriptionFileStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
|
|
|
var document = await XDocument.LoadAsync(descriptionFileStream, LoadOptions.None, cancellationToken);
|
|
XNamespace yamahaNamespace = "urn:schemas-yamaha-com:device-1-0";
|
|
|
|
var urlBaseNode = document.Descendants(yamahaNamespace + "X_URLBase").FirstOrDefault();
|
|
|
|
if (urlBaseNode is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var yamahaControlUrlNodes = document.Descendants(yamahaNamespace + "X_yxcControlURL");
|
|
|
|
foreach (var yamahaControlUrlNode in yamahaControlUrlNodes)
|
|
{
|
|
if (yamahaControlUrlNode is { Value: "/YamahaExtendedControl/v1/" })
|
|
{
|
|
return urlBaseNode.Value;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// ignored
|
|
}
|
|
|
|
return null;
|
|
}
|
|
} |