2026-09-09 01:05:52 +03:00
|
|
|
|
using System.Net;
|
|
|
|
|
|
using System.Net.Sockets;
|
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
|
using System.Text;
|
|
|
|
|
|
using MusicBridge.Agent.Media;
|
|
|
|
|
|
using MusicBridge.Agent.Network;
|
2026-09-08 23:23:55 +03:00
|
|
|
|
using Windows.Media.Control;
|
|
|
|
|
|
|
|
|
|
|
|
Console.OutputEncoding = Encoding.UTF8;
|
|
|
|
|
|
Console.Title = "MusicBridge Agent";
|
2026-09-09 01:05:52 +03:00
|
|
|
|
var lan = false;
|
|
|
|
|
|
var port = 8765;
|
|
|
|
|
|
for (var i = 0; i < args.Length; i++)
|
2026-09-08 23:23:55 +03:00
|
|
|
|
{
|
2026-09-09 01:05:52 +03:00
|
|
|
|
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
|
2026-09-08 23:23:55 +03:00
|
|
|
|
{
|
2026-09-09 01:05:52 +03:00
|
|
|
|
Console.WriteLine("Запуск: dotnet run -- [--lan] [--port 8765]");
|
|
|
|
|
|
Console.WriteLine("Без --lan соединения разрешены только с этого ПК. Веб-интерфейса нет.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-08 23:23:55 +03:00
|
|
|
|
|
2026-09-09 01:05:52 +03:00
|
|
|
|
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
|
2026-09-08 23:23:55 +03:00
|
|
|
|
{
|
2026-09-09 01:05:52 +03:00
|
|
|
|
addresses.AddRange(Dns.GetHostAddresses(Dns.GetHostName())
|
|
|
|
|
|
.Where(ip => ip.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(ip))
|
|
|
|
|
|
.Select(ip => $"https://{ip}:{port}"));
|
2026-09-08 23:23:55 +03:00
|
|
|
|
}
|
2026-09-09 01:05:52 +03:00
|
|
|
|
catch (SocketException) { }
|
|
|
|
|
|
}
|
|
|
|
|
|
var message = "Готов к управлению.";
|
|
|
|
|
|
IReadOnlyList<TrustedDevice> displayedDevices = [];
|
|
|
|
|
|
var nextRefresh = DateTimeOffset.MinValue;
|
|
|
|
|
|
while (!shutdown.IsCancellationRequested)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!Console.IsInputRedirected && Console.KeyAvailable)
|
2026-09-08 23:23:55 +03:00
|
|
|
|
{
|
2026-09-09 01:05:52 +03:00
|
|
|
|
var key = Console.ReadKey(intercept: true).Key;
|
|
|
|
|
|
if (key == ConsoleKey.Q) break;
|
|
|
|
|
|
if (key is >= ConsoleKey.D1 and <= ConsoleKey.D8)
|
2026-09-08 23:23:55 +03:00
|
|
|
|
{
|
2026-09-09 01:05:52 +03:00
|
|
|
|
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}"; }
|
|
|
|
|
|
}
|
2026-09-08 23:23:55 +03:00
|
|
|
|
}
|
2026-09-09 01:05:52 +03:00
|
|
|
|
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;
|
2026-09-08 23:23:55 +03:00
|
|
|
|
}
|
2026-09-09 01:05:52 +03:00
|
|
|
|
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);
|
2026-09-08 23:23:55 +03:00
|
|
|
|
}
|
2026-09-09 01:05:52 +03:00
|
|
|
|
}
|
|
|
|
|
|
catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
Console.Error.WriteLine($"Не удалось запустить или продолжить работу агента: {ex.Message}");
|
|
|
|
|
|
Environment.ExitCode = 1;
|
2026-09-08 23:23:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-09 01:05:52 +03:00
|
|
|
|
static string FormatTime(double seconds)
|
2026-09-08 23:23:55 +03:00
|
|
|
|
{
|
2026-09-09 01:05:52 +03:00
|
|
|
|
var time = TimeSpan.FromSeconds(seconds);
|
2026-09-08 23:23:55 +03:00
|
|
|
|
return time.TotalHours >= 1
|
|
|
|
|
|
? $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}"
|
|
|
|
|
|
: $"{(int)time.TotalMinutes}:{time.Seconds:00}";
|
2026-09-09 01:05:52 +03:00
|
|
|
|
}
|