Files
windows/Program.cs
T
ros d5e827112a Add MusicBridge protocol documentation and implement system volume control
- Created PROTOCOL.md to outline the MusicBridge protocol v1, detailing setup, TLS, pairing, state management, and command structure.
- Implemented SystemVolume class for managing system audio levels, including methods for setting, reading, changing, and muting volume.
- Added unit tests for the MusicBridge agent, covering pairing, identity storage, command validation, and media state management.
- Included tests for real-time media reading from Windows and ensured proper handling of artwork caching and retrieval.
2026-09-09 01:05:52 +03:00

160 lines
8.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using MusicBridge.Agent.Media;
using MusicBridge.Agent.Network;
using Windows.Media.Control;
Console.OutputEncoding = Encoding.UTF8;
Console.Title = "MusicBridge Agent";
var lan = false;
var port = 8765;
for (var i = 0; i < args.Length; i++)
{
if (args[i] == "--lan") lan = true;
else if (args[i] == "--port" && i + 1 < args.Length && int.TryParse(args[++i], out var parsed) && parsed is > 0 and <= 65535)
port = parsed;
else
{
Console.WriteLine("Запуск: dotnet run -- [--lan] [--port 8765]");
Console.WriteLine("Без --lan соединения разрешены только с этого ПК. Веб-интерфейса нет.");
return;
}
}
using var shutdown = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; shutdown.Cancel(); };
try
{
using var identity = new IdentityStore(IdentityStore.DefaultDirectory);
Console.WriteLine("Подключаемся к медиаплееру Windows...");
var manager = await GlobalSystemMediaTransportControlsSessionManager.RequestAsync();
using var media = new MediaService(manager);
var pairing = new PairingService(store: identity);
if (pairing.Devices.Count == 0) pairing.Open();
var certificate = identity.Certificate;
await using var server = new RemoteServer(media, pairing, certificate, lan, port);
await server.StartAsync(shutdown.Token);
using var hostStopping = server.Stopping.Register(() => shutdown.Cancel());
var fingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256);
var addresses = new List<string> { $"https://127.0.0.1:{port}" };
if (lan)
{
try
{
addresses.AddRange(Dns.GetHostAddresses(Dns.GetHostName())
.Where(ip => ip.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(ip))
.Select(ip => $"https://{ip}:{port}"));
}
catch (SocketException) { }
}
var message = "Готов к управлению.";
IReadOnlyList<TrustedDevice> displayedDevices = [];
var nextRefresh = DateTimeOffset.MinValue;
while (!shutdown.IsCancellationRequested)
{
if (!Console.IsInputRedirected && Console.KeyAvailable)
{
var key = Console.ReadKey(intercept: true).Key;
if (key == ConsoleKey.Q) break;
if (key is >= ConsoleKey.D1 and <= ConsoleKey.D8)
{
var index = (int)key - (int)ConsoleKey.D1;
if (index < displayedDevices.Count)
{
try
{
var device = displayedDevices[index];
message = pairing.Revoke(device.Id) ? $"Доступ отозван: {device.Name}." : "Устройство уже отключено.";
}
catch (Exception ex) { message = $"Не удалось сохранить отзыв доступа: {ex.Message}"; }
}
}
else if (key == ConsoleKey.I)
{
var invitationTime = DateTimeOffset.UtcNow;
var window = pairing.Current;
var endpoints = addresses.Select(a => new Uri(a)).Where(ConnectionInvitation.IsLocalEndpoint).ToArray();
message = window is null ? "Сначала нажми B, чтобы открыть сопряжение."
: endpoints.Length == 0 ? "Для данных подключения запусти агент с --lan в домашней сети."
: string.Join(Environment.NewLine, endpoints.Select(endpoint =>
$"Данные для будущего QR ({endpoint.Host}): {ConnectionInvitation.Create(endpoint, fingerprint, window, invitationTime).ToQrPayload()}"));
}
else if (key == ConsoleKey.B)
{
pairing.Open();
message = "Новый код сопряжения действует 5 минут.";
}
else
{
var command = key switch
{
ConsoleKey.Spacebar => new MediaCommand("toggle"),
ConsoleKey.N => new MediaCommand("next"),
ConsoleKey.P => new MediaCommand("previous"),
ConsoleKey.LeftArrow => new MediaCommand("seekRelative", -10),
ConsoleKey.RightArrow => new MediaCommand("seekRelative", 10),
ConsoleKey.UpArrow => new MediaCommand("volumeRelative", 0.05),
ConsoleKey.DownArrow => new MediaCommand("volumeRelative", -0.05),
ConsoleKey.M => new MediaCommand("toggleMute"),
_ => null
};
if (command is not null)
message = (await media.ExecuteAsync(command, shutdown.Token)).Message;
}
nextRefresh = DateTimeOffset.MinValue;
}
if (DateTimeOffset.UtcNow >= nextRefresh)
{
if (!Console.IsOutputRedirected) Console.Clear();
var state = server.Latest;
Console.WriteLine("MUSICBRIDGE\n");
if (state.HasSession)
{
Console.WriteLine($"Трек: {state.Title}");
Console.WriteLine($"Исполнитель: {state.Artist}");
Console.WriteLine($"Состояние: {state.PlaybackStatus}");
Console.WriteLine($"Позиция: {FormatTime(state.PositionSeconds)} / {FormatTime(state.EndSeconds)}");
Console.WriteLine($"Источник: {state.Source}");
Console.WriteLine($"Обложка: {(state.ArtworkId is not null ? "доступна" : state.ArtworkError ?? "нет у плеера")}");
}
else Console.WriteLine("Медиаплеер не найден. Включи музыку в браузере или приложении.");
if (state.MediaError is not null) Console.WriteLine($"Ошибка плеера: {state.MediaError}");
if (state.Volume is float volume)
Console.WriteLine($"Громкость: {volume:P0}{(state.Muted == true ? " (звук выключен)" : "")}");
else Console.WriteLine($"Громкость недоступна: {state.VolumeError}");
Console.WriteLine($"\nПодключение: {string.Join(" | ", addresses)}");
Console.WriteLine(lan ? "Режим локальной сети (IPv4)." : "Режим только этого ПК; для Wi-Fi запусти с --lan.");
Console.WriteLine($"SHA-256 сертификата: {fingerprint}");
var window = pairing.Current;
Console.WriteLine(window is null ? "Сопряжение закрыто. B — открыть заново."
: $"Код сопряжения: {window.Code} (до {window.ExpiresAt.ToLocalTime():HH:mm:ss})");
displayedDevices = pairing.Devices;
Console.WriteLine($"Доверенные устройства: {displayedDevices.Count}/8 (клавиша с номером — отозвать доступ)");
for (var i = 0; i < displayedDevices.Count; i++)
Console.WriteLine($" {i + 1}. {displayedDevices[i].Name}");
Console.WriteLine("\nПробел: play/pause | N/P: следующий/предыдущий трек");
Console.WriteLine("←/→: ±10 с | ↑/↓: громкость ±5% | M: звук | B: сопряжение");
Console.WriteLine("I: данные для будущего QR | Выход: Q или Ctrl+C");
Console.WriteLine($"\n{message}");
nextRefresh = DateTimeOffset.UtcNow.AddSeconds(1);
}
await Task.Delay(50, shutdown.Token);
}
}
catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
catch (Exception ex)
{
Console.Error.WriteLine($"Не удалось запустить или продолжить работу агента: {ex.Message}");
Environment.ExitCode = 1;
}
static string FormatTime(double seconds)
{
var time = TimeSpan.FromSeconds(seconds);
return time.TotalHours >= 1
? $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}"
: $"{(int)time.TotalMinutes}:{time.Seconds:00}";
}