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.
This commit is contained in:
2026-09-09 01:05:52 +03:00
parent 5e476c4c2f
commit d5e827112a
23 changed files with 2067 additions and 72 deletions
+43
View File
@@ -0,0 +1,43 @@
using System.Security.Cryptography;
namespace MusicBridge.Agent.Media;
internal sealed record Artwork(string Id, string ContentType, byte[] Bytes);
// Only the current cover is retained. IDs are content hashes, never file paths or URLs.
internal sealed class ArtworkCache
{
public const int MaxBytes = 2 * 1024 * 1024;
private Artwork? current;
public Artwork? Current => Volatile.Read(ref current);
public Artwork? Get(string id) => Current is { } artwork && artwork.Id == id ? artwork : null;
public void Clear() => Volatile.Write(ref current, null);
public async Task<Artwork> LoadAsync(Stream stream, CancellationToken cancellationToken)
{
Clear(); // A failed/new cover must not leave the previous track's image attached.
using var output = new MemoryStream();
var buffer = new byte[16 * 1024];
while (true)
{
var count = await stream.ReadAsync(buffer, cancellationToken);
if (count == 0) break;
if (output.Length + count > MaxBytes) throw new InvalidDataException("Обложка больше 2 МиБ.");
output.Write(buffer, 0, count);
}
var bytes = output.ToArray();
var type = DetectType(bytes) ?? throw new InvalidDataException("Неподдерживаемый формат обложки.");
var artwork = new Artwork(Convert.ToHexString(SHA256.HashData(bytes)), type, bytes);
Volatile.Write(ref current, artwork);
return artwork;
}
private static string? DetectType(ReadOnlySpan<byte> data)
{
if (data.StartsWith(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 })) return "image/png";
if (data.StartsWith(new byte[] { 255, 216, 255 })) return "image/jpeg";
if (data.StartsWith("GIF87a"u8) || data.StartsWith("GIF89a"u8)) return "image/gif";
if (data.Length >= 12 && data[..4].SequenceEqual("RIFF"u8) && data.Slice(8, 4).SequenceEqual("WEBP"u8)) return "image/webp";
return null;
}
}
+58
View File
@@ -0,0 +1,58 @@
namespace MusicBridge.Agent.Media;
internal sealed record MediaCommand(string Type, double? Value = null, string? Id = null);
internal sealed record CommandResult(bool Success, string Code, string Message, string? Id = null);
internal sealed record PlayerCapabilities(bool Play = false, bool Pause = false,
bool Toggle = false, bool Next = false, bool Previous = false, bool Seek = false);
internal sealed record MediaState
{
public int ProtocolVersion { get; init; } = 1;
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
public bool HasSession { get; init; }
public string Title { get; init; } = "";
public string Artist { get; init; } = "";
public string Source { get; init; } = "";
public string PlaybackStatus { get; init; } = "Closed";
public double PositionSeconds { get; init; }
public double StartSeconds { get; init; }
public double EndSeconds { get; init; }
public double MinSeekSeconds { get; init; }
public double MaxSeekSeconds { get; init; }
public double PlaybackRate { get; init; } = 1;
public PlayerCapabilities Capabilities { get; init; } = new();
public float? Volume { get; init; }
public bool? Muted { get; init; }
public string? MediaError { get; init; }
public string? VolumeError { get; init; }
public string? ArtworkId { get; init; }
public string? ArtworkError { get; init; }
}
internal interface IMediaService
{
event Action? Changed;
Artwork? GetArtwork(string id);
Task<MediaState> ReadAsync(CancellationToken cancellationToken);
Task<CommandResult> ExecuteAsync(MediaCommand command, CancellationToken cancellationToken);
}
internal static class CommandValidation
{
public static CommandResult? Validate(MediaCommand command)
{
if (command.Id?.Length > 64) return Error("invalid_command", "Id длиннее 64 символов.");
var valid = command.Type switch
{
"play" or "pause" or "toggle" or "next" or "previous" or "toggleMute" => command.Value is null,
"seek" => command.Value is double s && double.IsFinite(s) && s >= 0 && s <= TimeSpan.MaxValue.TotalSeconds / 2,
"seekRelative" => command.Value is double d && double.IsFinite(d) && Math.Abs(d) <= 3600,
"volume" => command.Value is double v && double.IsFinite(v) && v >= 0 && v <= 1,
"volumeRelative" => command.Value is double d && double.IsFinite(d) && Math.Abs(d) <= 1,
_ => false
};
return valid ? null : Error("invalid_command", "Неизвестная команда или недопустимое значение.");
}
public static CommandResult Error(string code, string message) => new(false, code, message);
}
+217
View File
@@ -0,0 +1,217 @@
using Windows.Media.Control;
namespace MusicBridge.Agent.Media;
internal sealed class MediaService : IMediaService, IDisposable
{
private readonly GlobalSystemMediaTransportControlsSessionManager manager;
private GlobalSystemMediaTransportControlsSession? observed;
private GlobalSystemMediaTransportControlsSessionMediaProperties? metadata;
private readonly ArtworkCache artwork = new();
private long metadataRevision;
private long loadedRevision = -1;
private DateTimeOffset nextMetadataRefresh;
private string? artworkError;
private bool disposed;
public event Action? Changed;
public Artwork? GetArtwork(string id) => artwork.Get(id);
public MediaService(GlobalSystemMediaTransportControlsSessionManager manager)
{
this.manager = manager;
manager.CurrentSessionChanged += OnCurrentSessionChanged;
manager.SessionsChanged += OnSessionsChanged;
}
// Console commands and remote clients must not race each other.
private readonly SemaphoreSlim gate = new(1, 1);
public async Task<MediaState> ReadAsync(CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken);
try
{
var state = new MediaState();
try
{
var session = manager.GetCurrentSession();
Observe(session);
if (session is not null)
{
var revision = Interlocked.Read(ref metadataRevision);
if (metadata is null || loadedRevision != revision || DateTimeOffset.UtcNow >= nextMetadataRefresh)
{
metadata = null;
artwork.Clear();
metadata = await session.TryGetMediaPropertiesAsync().AsTask(cancellationToken);
await LoadArtworkAsync(metadata, cancellationToken);
loadedRevision = revision; // Events during this read remain dirty for the next read.
nextMetadataRefresh = DateTimeOffset.UtcNow.AddSeconds(10);
}
var media = metadata;
var playback = session.GetPlaybackInfo();
var timeline = session.GetTimelineProperties();
var range = GetSeekRange(timeline);
var c = playback.Controls;
state = state with
{
HasSession = true, Title = media.Title, Artist = media.Artist,
Source = session.SourceAppUserModelId,
ArtworkId = artwork.Current?.Id, ArtworkError = artworkError,
PlaybackStatus = playback.PlaybackStatus.ToString(),
PositionSeconds = GetPosition(timeline, playback),
StartSeconds = timeline.StartTime.TotalSeconds, EndSeconds = timeline.EndTime.TotalSeconds,
MinSeekSeconds = range.Min, MaxSeekSeconds = range.Max,
PlaybackRate = double.IsFinite(playback.PlaybackRate ?? 1) ? playback.PlaybackRate ?? 1 : 1,
Capabilities = new(c.IsPlayEnabled, c.IsPauseEnabled, c.IsPlayPauseToggleEnabled,
c.IsNextEnabled, c.IsPreviousEnabled, c.IsPlaybackPositionEnabled && range.Max > range.Min)
};
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception ex)
{
metadata = null;
artwork.Clear();
state = state with { MediaError = ex.Message };
}
try
{
var volume = SystemVolume.Read();
state = state with { Volume = volume.Level, Muted = volume.Muted };
}
catch (Exception ex) { state = state with { VolumeError = ex.Message }; }
return state with { Timestamp = DateTimeOffset.UtcNow };
}
finally { gate.Release(); }
}
public async Task<CommandResult> ExecuteAsync(MediaCommand command, CancellationToken cancellationToken)
{
var invalid = CommandValidation.Validate(command);
if (invalid is not null) return invalid with { Id = command.Id };
await gate.WaitAsync(cancellationToken);
try
{
return (await ExecuteCoreAsync(command)) with { Id = command.Id };
}
catch (Exception ex)
{
return new(false, "operation_failed", ex.Message, command.Id);
}
finally { gate.Release(); Changed?.Invoke(); }
}
private async Task LoadArtworkAsync(GlobalSystemMediaTransportControlsSessionMediaProperties properties, CancellationToken cancellationToken)
{
artworkError = null;
if (properties.Thumbnail is null) return;
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(2));
try
{
using var source = await properties.Thumbnail.OpenReadAsync().AsTask(timeout.Token);
if (source.Size > ArtworkCache.MaxBytes) throw new InvalidDataException("Обложка больше 2 МиБ.");
using var stream = source.AsStreamForRead();
await artwork.LoadAsync(stream, timeout.Token);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (OperationCanceledException) { artworkError = "Время чтения обложки истекло."; }
catch (Exception ex) { artworkError = ex.Message; }
}
// Event callbacks only invalidate/notify; WinRT reads run under the shared gate.
private void OnCurrentSessionChanged(GlobalSystemMediaTransportControlsSessionManager sender, CurrentSessionChangedEventArgs args) => Invalidate();
private void OnSessionsChanged(GlobalSystemMediaTransportControlsSessionManager sender, SessionsChangedEventArgs args) => Invalidate();
private void OnMediaChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args) => Invalidate();
private void OnPlaybackChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args) => Changed?.Invoke();
private void OnTimelineChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args) => Changed?.Invoke();
private void Invalidate() { Interlocked.Increment(ref metadataRevision); Changed?.Invoke(); }
private void Observe(GlobalSystemMediaTransportControlsSession? session)
{
if (ReferenceEquals(observed, session)) return;
if (observed is not null)
{
observed.MediaPropertiesChanged -= OnMediaChanged;
observed.PlaybackInfoChanged -= OnPlaybackChanged;
observed.TimelinePropertiesChanged -= OnTimelineChanged;
}
observed = session;
metadata = null;
artwork.Clear();
artworkError = null;
if (observed is not null)
{
observed.MediaPropertiesChanged += OnMediaChanged;
observed.PlaybackInfoChanged += OnPlaybackChanged;
observed.TimelinePropertiesChanged += OnTimelineChanged;
}
}
public void Dispose()
{
if (disposed) return;
disposed = true;
manager.CurrentSessionChanged -= OnCurrentSessionChanged;
manager.SessionsChanged -= OnSessionsChanged;
Observe(null);
// Owner stops server/read loop before disposing the media service.
Changed = null;
gate.Dispose();
}
private async Task<CommandResult> ExecuteCoreAsync(MediaCommand command)
{
switch (command.Type)
{
case "volume": SystemVolume.Set((float)command.Value!.Value); return Ok();
case "volumeRelative": SystemVolume.Change((float)command.Value!.Value); return Ok();
case "toggleMute": SystemVolume.ToggleMute(); return Ok();
}
var session = manager.GetCurrentSession();
if (session is null) return CommandValidation.Error("no_session", "Медиаплеер не найден.");
var playback = session.GetPlaybackInfo();
var controls = playback.Controls;
bool accepted;
switch (command.Type)
{
case "play":
if (!controls.IsPlayEnabled) return Unsupported();
accepted = await session.TryPlayAsync(); break;
case "pause":
if (!controls.IsPauseEnabled) return Unsupported();
accepted = await session.TryPauseAsync(); break;
case "toggle":
if (!controls.IsPlayPauseToggleEnabled) return Unsupported();
accepted = await session.TryTogglePlayPauseAsync(); break;
case "next":
if (!controls.IsNextEnabled) return Unsupported();
accepted = await session.TrySkipNextAsync(); break;
case "previous":
if (!controls.IsPreviousEnabled) return Unsupported();
accepted = await session.TrySkipPreviousAsync(); break;
default:
if (!controls.IsPlaybackPositionEnabled) return Unsupported();
var timeline = session.GetTimelineProperties();
var range = GetSeekRange(timeline);
if (range.Max <= range.Min) return Unsupported();
var target = command.Value!.Value;
if (command.Type == "seekRelative") target += GetPosition(timeline, playback);
target = Math.Clamp(target, range.Min, range.Max);
accepted = await session.TryChangePlaybackPositionAsync(TimeSpan.FromSeconds(target).Ticks);
break;
}
return accepted ? Ok() : CommandValidation.Error("rejected", "Плеер отклонил команду.");
}
private static CommandResult Ok() => new(true, "ok", "Команда выполнена.");
private static CommandResult Unsupported() => CommandValidation.Error("unsupported", "Команда сейчас недоступна у плеера.");
private static (double Min, double Max) GetSeekRange(GlobalSystemMediaTransportControlsSessionTimelineProperties t)
=> TimelineMath.SeekRange(t.StartTime.TotalSeconds, t.EndTime.TotalSeconds, t.MinSeekTime.TotalSeconds, t.MaxSeekTime.TotalSeconds);
private static double GetPosition(GlobalSystemMediaTransportControlsSessionTimelineProperties t,
GlobalSystemMediaTransportControlsSessionPlaybackInfo p) => TimelineMath.Position(t.Position.TotalSeconds,
t.StartTime.TotalSeconds, t.EndTime.TotalSeconds,
p.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing,
t.LastUpdatedTime, DateTimeOffset.UtcNow, p.PlaybackRate ?? 1);
}
+16
View File
@@ -0,0 +1,16 @@
namespace MusicBridge.Agent.Media;
internal static class TimelineMath
{
public static double Position(double position, double start, double end, bool playing,
DateTimeOffset updatedAt, DateTimeOffset now, double rate)
{
if (playing && updatedAt > DateTimeOffset.UnixEpoch && updatedAt <= now && double.IsFinite(rate))
position += (now - updatedAt).TotalSeconds * rate;
position = Math.Max(start, position);
return end > start ? Math.Min(end, position) : position;
}
public static (double Min, double Max) SeekRange(double start, double end, double min, double max)
=> (Math.Max(start, min), end > start ? Math.Min(end, max) : max);
}