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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user