From 412014e2f87381901c572f6cbc8004d0d70849bf Mon Sep 17 00:00:00 2001 From: Nycro Date: Mon, 7 Oct 2024 23:53:25 -0400 Subject: [PATCH 1/5] Set up new serialization base --- .../Tracks/LavalinkTrack.IFormattable.cs | 398 --------- .../Tracks/LavalinkTrack.IParsable.cs | 204 ----- .../Tracks/LavalinkTrackDecoder.cs | 229 ----- .../LavalinkTrack.Deserialize.cs | 828 ++++++++++++++++++ .../LavalinkTrack.IFormattable.cs | 20 + .../Serialization/LavalinkTrack.IParsable.cs | 37 + .../Serialization/LavalinkTrack.Serialize.cs | 73 ++ .../LavalinkTrackTests.cs | 32 +- 8 files changed, 974 insertions(+), 847 deletions(-) delete mode 100644 src/Lavalink4NET.Abstractions/Tracks/LavalinkTrack.IFormattable.cs delete mode 100644 src/Lavalink4NET.Abstractions/Tracks/LavalinkTrack.IParsable.cs delete mode 100644 src/Lavalink4NET.Abstractions/Tracks/LavalinkTrackDecoder.cs create mode 100644 src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs create mode 100644 src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs create mode 100644 src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IParsable.cs create mode 100644 src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs diff --git a/src/Lavalink4NET.Abstractions/Tracks/LavalinkTrack.IFormattable.cs b/src/Lavalink4NET.Abstractions/Tracks/LavalinkTrack.IFormattable.cs deleted file mode 100644 index 64fef5f1..00000000 --- a/src/Lavalink4NET.Abstractions/Tracks/LavalinkTrack.IFormattable.cs +++ /dev/null @@ -1,398 +0,0 @@ -namespace Lavalink4NET.Tracks; - -using System; -using System.Buffers; -using System.Buffers.Binary; -using System.Buffers.Text; -using System.Text.Unicode; - -public partial record class LavalinkTrack : ISpanFormattable -{ - public override string ToString() - { - return ToString(version: null, format: null, formatProvider: null); - } - - public string ToString(string? format, IFormatProvider? formatProvider) - { - return ToString(version: null, format: format, formatProvider: formatProvider); - } - - public string ToString(int? version) - { - return ToString(version: version, format: null, formatProvider: null); - } - - public string ToString(int? version, string? format, IFormatProvider? formatProvider) - { - // The ToString method is culture-neutral and format-neutral - if (TrackData is not null && version is null) - { - return TrackData; - } - - Span buffer = stackalloc char[256]; - - int charsWritten; - while (!TryFormat(buffer, out charsWritten, version, format ?? default, formatProvider)) - { - buffer = GC.AllocateUninitializedArray(buffer.Length * 2); - } - - var trackData = new string(buffer[..charsWritten]); - - if (version is null) - { - TrackData = trackData; - } - - return trackData; - } - - public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) - { - return TryFormat(destination, out charsWritten, version: null, format, provider); - } - -#pragma warning disable IDE0060 - public bool TryFormat(Span destination, out int charsWritten, int? version, ReadOnlySpan format, IFormatProvider? provider) -#pragma warning restore IDE0060 - { - var buffer = ArrayPool.Shared.Rent(destination.Length); - - try - { - var result = TryEncode(buffer, version, out var bytesWritten); - - if (!result) - { - charsWritten = default; - return false; - } - - var operationStatus = Base64.EncodeToUtf8InPlace( - buffer: buffer, - dataLength: bytesWritten, - bytesWritten: out var base64BytesWritten); - - if (operationStatus is not OperationStatus.Done) - { - if (operationStatus is OperationStatus.DestinationTooSmall) - { - charsWritten = default; - return false; - } - - throw new InvalidOperationException("Error while encoding to Base64."); - } - - operationStatus = Utf8.ToUtf16( - source: buffer.AsSpan(0, base64BytesWritten), - destination: destination, - bytesRead: out _, - charsWritten: out charsWritten); - - if (operationStatus is not OperationStatus.Done) - { - if (operationStatus is OperationStatus.DestinationTooSmall) - { - charsWritten = default; - return false; - } - - throw new InvalidOperationException("Error while encoding to UTF-8."); - } - - return true; - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - internal bool TryEncode(Span buffer, int? version, out int bytesWritten) - { - var versionValue = version ?? 3; - - if (versionValue is not 2 and not 3) - { - throw new ArgumentOutOfRangeException(nameof(version)); - } - - if (SourceName is null) - { - throw new InvalidOperationException("Unknown source."); - } - - var isProbingAudioTrack = IsProbingTrack(SourceName); - - if (isProbingAudioTrack && ProbeInfo is null) - { - throw new InvalidOperationException("For the HTTP and local source audio manager, a probe info must be given."); - } - - if (buffer.Length < 5) - { - bytesWritten = 0; - return false; - } - - // Reserve 5 bytes for the header - var headerBuffer = buffer[..5]; - buffer = buffer[5..]; - bytesWritten = 5; - - // Write title and author - if (!TryEncodeString(ref buffer, Title, ref bytesWritten) || - !TryEncodeString(ref buffer, Author, ref bytesWritten)) - { - return false; - } - - // Write track duration - if (buffer.Length < 8) - { - return false; - } - - var duration = Duration == TimeSpan.MaxValue - ? long.MaxValue - : (long)Math.Round(Duration.TotalMilliseconds); - - BinaryPrimitives.WriteInt64BigEndian( - destination: buffer[..8], - value: duration); - - buffer = buffer[8..]; - bytesWritten += 8; - - // Write track identifier - if (!TryEncodeString(ref buffer, Identifier, ref bytesWritten)) - { - return false; - } - - // Write stream flag - if (buffer.Length < 1) - { - return false; - } - - buffer[0] = (byte)(IsLiveStream ? 1 : 0); - - bytesWritten++; - buffer = buffer[1..]; - - var rawUri = Uri is null ? string.Empty : Uri.ToString(); - - if (!TryEncodeOptionalString(ref buffer, rawUri, ref bytesWritten)) - { - return false; - } - - if (versionValue >= 3) - { - var rawArtworkUri = ArtworkUri is null ? string.Empty : ArtworkUri.ToString(); - - if (!TryEncodeOptionalString(ref buffer, rawArtworkUri, ref bytesWritten) || - !TryEncodeOptionalString(ref buffer, Isrc, ref bytesWritten)) - { - return false; - } - } - - // Write source name - if (!TryEncodeString(ref buffer, SourceName, ref bytesWritten)) - { - return false; - } - - // Write probe information - if (isProbingAudioTrack && !TryEncodeString(ref buffer, ProbeInfo, ref bytesWritten)) - { - return false; - } - - if (IsExtendedTrack(SourceName)) - { - bool TryEncodeOptionalJsonString(ref Span buffer, string propertyName, ref int bytesWritten) - { - var value = AdditionalInformation.TryGetValue(propertyName, out var jsonElement) - ? jsonElement.GetString()! - : string.Empty; - - return TryEncodeOptionalString(ref buffer, value, ref bytesWritten); - } - - if (!TryEncodeOptionalJsonString(ref buffer, "albumName", ref bytesWritten) || - !TryEncodeOptionalJsonString(ref buffer, "albumUrl", ref bytesWritten) || - !TryEncodeOptionalJsonString(ref buffer, "artistUrl", ref bytesWritten) || - !TryEncodeOptionalJsonString(ref buffer, "artistArtworkUrl", ref bytesWritten) || - !TryEncodeOptionalJsonString(ref buffer, "previewUrl", ref bytesWritten)) - { - return false; - } - - var isPreview = AdditionalInformation.TryGetValue("isPreview", out var isPreviewElement) && isPreviewElement.GetBoolean(); - - if (buffer.Length < 1) - { - return false; - } - - buffer[0] = (byte)(isPreview ? 1 : 0); - bytesWritten++; - buffer = buffer[1..]; - } - - // Write track start position - if (buffer.Length < 8) - { - return false; - } - - BinaryPrimitives.WriteInt64BigEndian( - destination: buffer[..8], - value: (long)Math.Round(StartPosition?.TotalMilliseconds ?? 0)); - - // buffer = buffer[8..]; - bytesWritten += 8; - - var payloadLength = bytesWritten - 4; - EncodeHeader(headerBuffer, payloadLength, (byte)versionValue); - - return true; - } - - private static void EncodeHeader(Span headerBuffer, int payloadLength, byte version) - { - // Set "has version" in header - var header = 0b01000000000000000000000000000000 | payloadLength; - BinaryPrimitives.WriteInt32BigEndian(headerBuffer, header); - - // version - headerBuffer[4] = version; - } - - private static bool TryEncodeString(ref Span span, ReadOnlySpan value, ref int bytesWritten) - { - if (span.Length < 2) - { - return false; - } - - var lengthBuffer = span[..2]; - span = span[2..]; - - var previousBytesWritten = bytesWritten; - - if (!TryWriteModifiedUtf8(ref span, value, ref bytesWritten)) - { - return false; - } - - var utf8BytesWritten = bytesWritten - previousBytesWritten; - - BinaryPrimitives.WriteUInt16BigEndian(lengthBuffer, (ushort)utf8BytesWritten); - - bytesWritten += 2; - - return true; - } - - private static bool TryEncodeOptionalString(ref Span span, ReadOnlySpan value, ref int bytesWritten) - { - if (span.Length < 1) - { - return false; - } - - var present = !value.IsWhiteSpace(); - - span[0] = (byte)(present ? 1 : 0); - span = span[1..]; - bytesWritten++; - - if (!present) - { - return true; - } - - if (!TryEncodeString(ref span, value, ref bytesWritten)) - { - return false; - } - - return true; - } - - private static bool TryWriteModifiedUtf8(ref Span span, ReadOnlySpan value, ref int bytesWritten) - { - // Ported from https://android.googlesource.com/platform/prebuilts/fullsdk/sources/android-29/+/refs/heads/androidx-wear-release/java/io/DataOutputStream.java - - int index; - for (index = 0; index < value.Length; index++) - { - var character = value[index]; - - if (character is not (>= (char)0x0001 and <= (char)0x007F)) - { - break; - } - - if (span.IsEmpty) - { - return false; - } - - span[0] = (byte)character; - bytesWritten++; - span = span[1..]; - } - - for (; index < value.Length; index++) - { - var character = value[index]; - - if (character is >= (char)0x0001 and <= (char)0x007F) - { - if (span.IsEmpty) - { - return false; - } - - span[0] = (byte)character; - bytesWritten++; - span = span[1..]; - } - else if (character > 0x07FF) - { - if (span.Length < 3) - { - return false; - } - - span[0] = (byte)(0xE0 | ((character >> 12) & 0x0F)); - span[1] = (byte)(0x80 | ((character >> 6) & 0x3F)); - span[2] = (byte)(0x80 | ((character >> 0) & 0x3F)); - bytesWritten += 3; - span = span[3..]; - } - else - { - if (span.Length < 2) - { - return false; - } - - span[0] = (byte)(0xC0 | ((character >> 6) & 0x1F)); - span[1] = (byte)(0x80 | ((character >> 0) & 0x3F)); - bytesWritten += 2; - span = span[2..]; - } - } - - return true; - } -} diff --git a/src/Lavalink4NET.Abstractions/Tracks/LavalinkTrack.IParsable.cs b/src/Lavalink4NET.Abstractions/Tracks/LavalinkTrack.IParsable.cs deleted file mode 100644 index b04d42bd..00000000 --- a/src/Lavalink4NET.Abstractions/Tracks/LavalinkTrack.IParsable.cs +++ /dev/null @@ -1,204 +0,0 @@ -namespace Lavalink4NET.Tracks; - -using System; -using System.Buffers; -using System.Buffers.Text; -using System.Collections.Immutable; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Unicode; - -public partial record class LavalinkTrack -#if NET7_0_OR_GREATER - : ISpanParsable -#endif -{ - public static LavalinkTrack Parse(ReadOnlySpan s, IFormatProvider? provider) - { - if (!TryParse(s, provider, out var result)) - { - throw new ArgumentException("Invalid track.", nameof(s)); - } - - return result; - } - - public static LavalinkTrack Parse(string s, IFormatProvider? provider) - { - return Parse(s.AsSpan(), provider); - } - - internal static bool TryParse(ReadOnlySpan originalTrackData, ref LavalinkTrackDecoder trackDecoder, [MaybeNullWhen(false)] out LavalinkTrack result) - { - result = null; - - if (!trackDecoder.TryReadHeader(out var version) || - !trackDecoder.TryReadString(out var title) || - !trackDecoder.TryReadString(out var author) || - !trackDecoder.TryReadInt64(out var durationValue) || - !trackDecoder.TryReadString(out var identifier) || - !trackDecoder.TryReadBoolean(out var isStream) || - !trackDecoder.TryReadOptionalString(out var rawUri)) - { - return false; - } - - var rawArtworkUri = default(string?); - var isrc = default(string?); - - if (version >= 3 && (!trackDecoder.TryReadOptionalString(out rawArtworkUri) || !trackDecoder.TryReadOptionalString(out isrc))) - { - return false; - } - - var uri = default(Uri?); - if (rawUri is not null && !Uri.TryCreate(rawUri, UriKind.Absolute, out uri)) - { - return false; - } - - var artworkUri = default(Uri?); - if (rawArtworkUri is not null && !Uri.TryCreate(rawArtworkUri, UriKind.Absolute, out artworkUri)) - { - return false; - } - - if (!trackDecoder.TryReadString(out var sourceName)) - { - return false; - } - - var containerProbeInformation = default(string?); - - if (IsProbingTrack(sourceName) && !trackDecoder.TryReadString(out containerProbeInformation)) - { - return false; - } - - var additionalInformationBuilder = ImmutableDictionary.CreateBuilder(); - - if (IsExtendedTrack(sourceName)) - { - if (!trackDecoder.TryReadOptionalString(out var albumName) || - !trackDecoder.TryReadOptionalString(out var rawAlbumUri) || - !trackDecoder.TryReadOptionalString(out var rawArtistUri) || - !trackDecoder.TryReadOptionalString(out var rawArtistArtworkUri) || - !trackDecoder.TryReadOptionalString(out var rawPreviewUri) || - !trackDecoder.TryReadBoolean(out var isPreview)) - { - return false; - } - - var data = new JsonObject - { - {"albumName", albumName }, - {"albumUrl", rawAlbumUri }, - {"artistUrl", rawArtistUri }, - {"artistArtworkUrl", rawArtistArtworkUri }, - {"previewUrl", rawPreviewUri }, - {"isPreview", isPreview }, - }; - - var bufferWriter = new ArrayBufferWriter(); - using var utf8JsonWriter = new Utf8JsonWriter(bufferWriter); - data.WriteTo(utf8JsonWriter); - utf8JsonWriter.Dispose(); - - var utf8JsonReader = new Utf8JsonReader(bufferWriter.WrittenSpan); - var jsonDocument = JsonElement.ParseValue(ref utf8JsonReader); - - additionalInformationBuilder.Add("albumName", jsonDocument.GetProperty("albumName")); - additionalInformationBuilder.Add("albumUrl", jsonDocument.GetProperty("albumUrl")); - additionalInformationBuilder.Add("artistUrl", jsonDocument.GetProperty("artistUrl")); - additionalInformationBuilder.Add("artistArtworkUrl", jsonDocument.GetProperty("artistArtworkUrl")); - additionalInformationBuilder.Add("previewUrl", jsonDocument.GetProperty("previewUrl")); - additionalInformationBuilder.Add("isPreview", jsonDocument.GetProperty("isPreview")); - } - - if (!trackDecoder.TryReadInt64(out var startPositionValue)) - { - return false; - } - - var startPosition = startPositionValue is 0 - ? default(TimeSpan?) - : TimeSpan.FromMilliseconds(startPositionValue); - - var duration = durationValue >= TimeSpan.MaxValue.TotalMilliseconds - ? TimeSpan.MaxValue - : TimeSpan.FromMilliseconds(durationValue); - - result = new LavalinkTrack - { - Author = author, - Identifier = identifier, - Title = title, - Duration = duration, - IsLiveStream = isStream, - IsSeekable = !isStream, - ProbeInfo = containerProbeInformation, - SourceName = sourceName, - StartPosition = startPosition, - Uri = uri, - ArtworkUri = artworkUri, - Isrc = isrc, - TrackData = originalTrackData.ToString(), - AdditionalInformation = additionalInformationBuilder.ToImmutable(), - }; - - return true; - } - - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out LavalinkTrack result) - { - var pool = ArrayPool.Shared.Rent(s.Length); - - try - { - var operationStatus = Utf8.FromUtf16( - source: s, - destination: pool, - charsRead: out _, - bytesWritten: out var utf8BytesWritten); - - if (operationStatus is not OperationStatus.Done) - { - Debug.Assert(operationStatus is not OperationStatus.DestinationTooSmall); - - result = null; - return false; - } - - operationStatus = Base64.DecodeFromUtf8InPlace( - buffer: pool.AsSpan(0, utf8BytesWritten), - bytesWritten: out var decodedBytesWritten); - - if (operationStatus is not OperationStatus.Done) - { - Debug.Assert(operationStatus is not OperationStatus.DestinationTooSmall); - - result = null; - return false; - } - - return TryParse(s, pool.AsSpan(0, decodedBytesWritten), out result); - } - finally - { - ArrayPool.Shared.Return(pool); - } - } - - internal static bool TryParse(ReadOnlySpan originalTrackData, ReadOnlySpan buffer, [MaybeNullWhen(false)] out LavalinkTrack result) - { - var trackDecoder = new LavalinkTrackDecoder(buffer); - return TryParse(originalTrackData, ref trackDecoder, out result); - } - - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out LavalinkTrack result) - { - return TryParse(s is null ? default : s.AsSpan(), provider, out result); - } -} diff --git a/src/Lavalink4NET.Abstractions/Tracks/LavalinkTrackDecoder.cs b/src/Lavalink4NET.Abstractions/Tracks/LavalinkTrackDecoder.cs deleted file mode 100644 index 892d1c1c..00000000 --- a/src/Lavalink4NET.Abstractions/Tracks/LavalinkTrackDecoder.cs +++ /dev/null @@ -1,229 +0,0 @@ -namespace Lavalink4NET.Tracks; - -using System; -using System.Buffers.Binary; -using System.Diagnostics.CodeAnalysis; - -internal ref struct LavalinkTrackDecoder -{ - public LavalinkTrackDecoder(ReadOnlySpan buffer) - { - Buffer = buffer; - } - - public ReadOnlySpan Buffer { get; set; } - - public bool TryReadHeader(out int version) - { - version = 1; - - if (Buffer.Length is < 4) - { - return false; - } - - // the header is four bytes long, subtract - var header = BinaryPrimitives.ReadUInt32BigEndian(Buffer); - Buffer = Buffer[4..]; - - var flags = (int)((header & 0xC0000000L) >> 30); - var hasVersion = (flags & 1) is not 0; - - // verify size - var size = header & 0x3FFFFFFF; - - if (size != Buffer.Length) - { - // Invalid following payload length - return false; - } - - if (hasVersion) - { - if (Buffer.IsEmpty) - { - // Missing version - return false; - } - - version = Buffer[0]; - Buffer = Buffer[1..]; - } - - // verify version - if (version is not 2 and not 3) - { - // unsupported version - return false; - } - - return true; - } - - public bool TryReadBoolean(out bool value) - { - if (Buffer.IsEmpty) - { - value = default; - return false; - } - - value = Buffer[0] is not 0; - Buffer = Buffer[1..]; - return true; - } - - public bool TryReadInt64(out long value) - { - if (Buffer.Length < 8) - { - value = default; - return false; - } - - value = BinaryPrimitives.ReadInt64BigEndian(Buffer); - Buffer = Buffer[8..]; - return true; - } - - public bool TryReadOptionalString(out string? value) - { - if (!TryReadBoolean(out var isPresent)) - { - value = default; - return false; - } - - if (!isPresent) - { - value = null; - return true; - } - - return TryReadString(out value); - } - - public bool TryReadString([MaybeNullWhen(false)] out string value) - { - if (Buffer.Length < 2) - { - value = default; - return false; - } - - var length = BinaryPrimitives.ReadUInt16BigEndian(Buffer); - Buffer = Buffer[2..]; - - if (Buffer.Length < length) - { - value = default; - return false; - } - - var stringBuffer = Buffer[..length]; - Buffer = Buffer[length..]; - - value = ReadModifiedUtf8(stringBuffer); - return true; - } - - private static string ReadModifiedUtf8(ReadOnlySpan value) - { - // Ported from https://android.googlesource.com/platform/prebuilts/fullsdk/sources/android-29/+/refs/heads/androidx-wear-release/java/io/DataInputStream.java - - Span buffer = value.Length < 256 - ? stackalloc char[256] - : GC.AllocateUninitializedArray(value.Length * 2); - - var length = value.Length; - var count = 0; - var charactersWritten = 0; - - // Fast-read all ASCII characters - while (!value.IsEmpty) - { - var character = value[0]; - - if (character > 127) - { - break; - } - - count++; - value = value[1..]; - buffer[charactersWritten++] = (char)character; - } - - while (!value.IsEmpty) - { - var character = value[0]; - - switch (character >> 4) - { - case 0: - case 1: - case 2: - case 3: - case 4: - case 5: - case 6: - case 7: - // 0xxxxxxx - count++; - buffer[charactersWritten++] = (char)character; - value = value[1..]; - break; - - case 12: - case 13: - // 110x xxxx 10xx xxxx - count += 2; - - if (count > length) - { - throw new InvalidDataException("Found partial character at end."); - } - - var additionalCharacter = value[1]; - - if ((additionalCharacter & 0xC0) != 0x80) - { - throw new InvalidDataException($"malformed input around byte {count}"); - } - - buffer[charactersWritten++] = (char)(((character & 0x1F) << 6) | (additionalCharacter & 0x3F)); - value = value[2..]; - - break; - - case 14: - // 1110 xxxx 10xx xxxx 10xx xxxx - count += 3; - - if (count > length) - { - throw new InvalidDataException("Found malformed input due to partial character at end"); - } - - var secondCharacter = (int)value[1]; - var thirdCharacter = (int)value[2]; - - if (((secondCharacter & 0xC0) != 0x80) || ((thirdCharacter & 0xC0) != 0x80)) - { - throw new InvalidDataException($"Found malformed input around byte {count - 1}"); - } - - buffer[charactersWritten++] = (char)(((character & 0x0F) << 12) | ((secondCharacter & 0x3F) << 6) | ((thirdCharacter & 0x3F) << 0)); - value = value[3..]; - - break; - - default: - // 10xx xxxx, 1111 xxxx - throw new InvalidDataException($"Found malformed input around byte {count}"); - } - } - - return buffer[..charactersWritten].ToString(); - } -} diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs new file mode 100644 index 00000000..02b3c347 --- /dev/null +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs @@ -0,0 +1,828 @@ +using System.Buffers.Text; +using System.Buffers; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Unicode; +using System.Collections.Immutable; +using System.Text.Json.Nodes; +using System.Text.Json; +using System.Buffers.Binary; + +namespace Lavalink4NET.Tracks +{ + public partial record class LavalinkTrack + { + public static LavalinkTrack Deserialize(byte[] data) + { + + } + + // These LEGACY regions are a temporary measure in place while I work on re-writing the serialization system. + // The plan is to automatically detect legacy encoding formats and implement these methods within the new decoding system. + // ... but I need to actually write the new decoding system first. + // + // - Nycro, Oct. 7, 2024 + + #region LEGACY PARSING + + public static LavalinkTrack ParseLegacy(string s, IFormatProvider? provider) + { + return ParseLegacy(s.AsSpan(), provider); + } + + public static LavalinkTrack ParseLegacy(ReadOnlySpan s, IFormatProvider? provider) + { + if (!TryParseLegacy(s, provider, out var result)) + { + throw new ArgumentException("Invalid track.", nameof(s)); + } + + return result; + } + + public static bool TryParseLegacy([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out LavalinkTrack result) + { + return TryParseLegacy(s is null ? default : s.AsSpan(), provider, out result); + } + + public static bool TryParseLegacy(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out LavalinkTrack result) + { + var pool = ArrayPool.Shared.Rent(s.Length); + + try + { + var operationStatus = Utf8.FromUtf16( + source: s, + destination: pool, + charsRead: out _, + bytesWritten: out var utf8BytesWritten); + + if (operationStatus is not OperationStatus.Done) + { + Debug.Assert(operationStatus is not OperationStatus.DestinationTooSmall); + + result = null; + return false; + } + + operationStatus = Base64.DecodeFromUtf8InPlace( + buffer: pool.AsSpan(0, utf8BytesWritten), + bytesWritten: out var decodedBytesWritten); + + if (operationStatus is not OperationStatus.Done) + { + Debug.Assert(operationStatus is not OperationStatus.DestinationTooSmall); + + result = null; + return false; + } + + return TryParseLegacy(s, pool.AsSpan(0, decodedBytesWritten), out result); + } + finally + { + ArrayPool.Shared.Return(pool); + } + } + + internal static bool TryParseLegacy(ReadOnlySpan originalTrackData, ReadOnlySpan buffer, [MaybeNullWhen(false)] out LavalinkTrack result) + { + var trackDecoder = new LavalinkTrackDecoder(buffer); + return TryParseLegacy(originalTrackData, ref trackDecoder, out result); + } + + internal static bool TryParseLegacy(ReadOnlySpan originalTrackData, ref LavalinkTrackDecoder trackDecoder, [MaybeNullWhen(false)] out LavalinkTrack result) + { + result = null; + + if (!trackDecoder.TryReadHeader(out var version) || + !trackDecoder.TryReadString(out var title) || + !trackDecoder.TryReadString(out var author) || + !trackDecoder.TryReadInt64(out var durationValue) || + !trackDecoder.TryReadString(out var identifier) || + !trackDecoder.TryReadBoolean(out var isStream) || + !trackDecoder.TryReadOptionalString(out var rawUri)) + { + return false; + } + + var rawArtworkUri = default(string?); + var isrc = default(string?); + + if (version >= 3 && (!trackDecoder.TryReadOptionalString(out rawArtworkUri) || !trackDecoder.TryReadOptionalString(out isrc))) + { + return false; + } + + var uri = default(Uri?); + if (rawUri is not null && !Uri.TryCreate(rawUri, UriKind.Absolute, out uri)) + { + return false; + } + + var artworkUri = default(Uri?); + if (rawArtworkUri is not null && !Uri.TryCreate(rawArtworkUri, UriKind.Absolute, out artworkUri)) + { + return false; + } + + if (!trackDecoder.TryReadString(out var sourceName)) + { + return false; + } + + var containerProbeInformation = default(string?); + + if (IsProbingTrack(sourceName) && !trackDecoder.TryReadString(out containerProbeInformation)) + { + return false; + } + + var additionalInformationBuilder = ImmutableDictionary.CreateBuilder(); + + if (IsExtendedTrack(sourceName)) + { + if (!trackDecoder.TryReadOptionalString(out var albumName) || + !trackDecoder.TryReadOptionalString(out var rawAlbumUri) || + !trackDecoder.TryReadOptionalString(out var rawArtistUri) || + !trackDecoder.TryReadOptionalString(out var rawArtistArtworkUri) || + !trackDecoder.TryReadOptionalString(out var rawPreviewUri) || + !trackDecoder.TryReadBoolean(out var isPreview)) + { + return false; + } + + var data = new JsonObject + { + {"albumName", albumName }, + {"albumUrl", rawAlbumUri }, + {"artistUrl", rawArtistUri }, + {"artistArtworkUrl", rawArtistArtworkUri }, + {"previewUrl", rawPreviewUri }, + {"isPreview", isPreview }, + }; + + var bufferWriter = new ArrayBufferWriter(); + using var utf8JsonWriter = new Utf8JsonWriter(bufferWriter); + data.WriteTo(utf8JsonWriter); + utf8JsonWriter.Dispose(); + + var utf8JsonReader = new Utf8JsonReader(bufferWriter.WrittenSpan); + var jsonDocument = JsonElement.ParseValue(ref utf8JsonReader); + + additionalInformationBuilder.Add("albumName", jsonDocument.GetProperty("albumName")); + additionalInformationBuilder.Add("albumUrl", jsonDocument.GetProperty("albumUrl")); + additionalInformationBuilder.Add("artistUrl", jsonDocument.GetProperty("artistUrl")); + additionalInformationBuilder.Add("artistArtworkUrl", jsonDocument.GetProperty("artistArtworkUrl")); + additionalInformationBuilder.Add("previewUrl", jsonDocument.GetProperty("previewUrl")); + additionalInformationBuilder.Add("isPreview", jsonDocument.GetProperty("isPreview")); + } + + if (!trackDecoder.TryReadInt64(out var startPositionValue)) + { + return false; + } + + var startPosition = startPositionValue is 0 + ? default(TimeSpan?) + : TimeSpan.FromMilliseconds(startPositionValue); + + var duration = durationValue >= TimeSpan.MaxValue.TotalMilliseconds + ? TimeSpan.MaxValue + : TimeSpan.FromMilliseconds(durationValue); + + result = new LavalinkTrack + { + Author = author, + Identifier = identifier, + Title = title, + Duration = duration, + IsLiveStream = isStream, + IsSeekable = !isStream, + ProbeInfo = containerProbeInformation, + SourceName = sourceName, + StartPosition = startPosition, + Uri = uri, + ArtworkUri = artworkUri, + Isrc = isrc, + TrackData = originalTrackData.ToString(), + AdditionalInformation = additionalInformationBuilder.ToImmutable(), + }; + + return true; + } + + internal ref struct LavalinkTrackDecoder(ReadOnlySpan buffer) + { + public ReadOnlySpan Buffer { get; set; } = buffer; + + public bool TryReadHeader(out int version) + { + version = 1; + + if (Buffer.Length is < 4) + { + return false; + } + + // the header is four bytes long, subtract + var header = BinaryPrimitives.ReadUInt32BigEndian(Buffer); + Buffer = Buffer[4..]; + + var flags = (int)((header & 0xC0000000L) >> 30); + var hasVersion = (flags & 1) is not 0; + + // verify size + var size = header & 0x3FFFFFFF; + + if (size != Buffer.Length) + { + // Invalid following payload length + return false; + } + + if (hasVersion) + { + if (Buffer.IsEmpty) + { + // Missing version + return false; + } + + version = Buffer[0]; + Buffer = Buffer[1..]; + } + + // verify version + if (version is not 2 and not 3) + { + // unsupported version + return false; + } + + return true; + } + + public bool TryReadBoolean(out bool value) + { + if (Buffer.IsEmpty) + { + value = default; + return false; + } + + value = Buffer[0] is not 0; + Buffer = Buffer[1..]; + return true; + } + + public bool TryReadInt64(out long value) + { + if (Buffer.Length < 8) + { + value = default; + return false; + } + + value = BinaryPrimitives.ReadInt64BigEndian(Buffer); + Buffer = Buffer[8..]; + return true; + } + + public bool TryReadOptionalString(out string? value) + { + if (!TryReadBoolean(out var isPresent)) + { + value = default; + return false; + } + + if (!isPresent) + { + value = null; + return true; + } + + return TryReadString(out value); + } + + public bool TryReadString([MaybeNullWhen(false)] out string value) + { + if (Buffer.Length < 2) + { + value = default; + return false; + } + + var length = BinaryPrimitives.ReadUInt16BigEndian(Buffer); + Buffer = Buffer[2..]; + + if (Buffer.Length < length) + { + value = default; + return false; + } + + var stringBuffer = Buffer[..length]; + Buffer = Buffer[length..]; + + value = ReadModifiedUtf8(stringBuffer); + return true; + } + + private static string ReadModifiedUtf8(ReadOnlySpan value) + { + // Ported from https://android.googlesource.com/platform/prebuilts/fullsdk/sources/android-29/+/refs/heads/androidx-wear-release/java/io/DataInputStream.java + + Span buffer = value.Length < 256 + ? stackalloc char[256] + : GC.AllocateUninitializedArray(value.Length * 2); + + var length = value.Length; + var count = 0; + var charactersWritten = 0; + + // Fast-read all ASCII characters + while (!value.IsEmpty) + { + var character = value[0]; + + if (character > 127) + { + break; + } + + count++; + value = value[1..]; + buffer[charactersWritten++] = (char)character; + } + + while (!value.IsEmpty) + { + var character = value[0]; + + switch (character >> 4) + { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + // 0xxxxxxx + count++; + buffer[charactersWritten++] = (char)character; + value = value[1..]; + break; + + case 12: + case 13: + // 110x xxxx 10xx xxxx + count += 2; + + if (count > length) + { + throw new InvalidDataException("Found partial character at end."); + } + + var additionalCharacter = value[1]; + + if ((additionalCharacter & 0xC0) != 0x80) + { + throw new InvalidDataException($"malformed input around byte {count}"); + } + + buffer[charactersWritten++] = (char)(((character & 0x1F) << 6) | (additionalCharacter & 0x3F)); + value = value[2..]; + + break; + + case 14: + // 1110 xxxx 10xx xxxx 10xx xxxx + count += 3; + + if (count > length) + { + throw new InvalidDataException("Found malformed input due to partial character at end"); + } + + var secondCharacter = (int)value[1]; + var thirdCharacter = (int)value[2]; + + if (((secondCharacter & 0xC0) != 0x80) || ((thirdCharacter & 0xC0) != 0x80)) + { + throw new InvalidDataException($"Found malformed input around byte {count - 1}"); + } + + buffer[charactersWritten++] = (char)(((character & 0x0F) << 12) | ((secondCharacter & 0x3F) << 6) | ((thirdCharacter & 0x3F) << 0)); + value = value[3..]; + + break; + + default: + // 10xx xxxx, 1111 xxxx + throw new InvalidDataException($"Found malformed input around byte {count}"); + } + } + + return buffer[..charactersWritten].ToString(); + } + } + + #endregion + + #region LEGACY FORMATTING + + public string ToStringLegacy() + { + return ToStringLegacy(version: null, format: null, formatProvider: null); + } + + public string ToStringLegacy(string? format, IFormatProvider? formatProvider) + { + return ToStringLegacy(version: null, format: format, formatProvider: formatProvider); + } + + public string ToStringLegacy(int? version) + { + return ToStringLegacy(version: version, format: null, formatProvider: null); + } + + public string ToStringLegacy(int? version, string? format, IFormatProvider? formatProvider) + { + // The ToString method is culture-neutral and format-neutral + if (TrackData is not null && version is null) + { + return TrackData; + } + + Span buffer = stackalloc char[256]; + + int charsWritten; + while (!TryFormatLegacy(buffer, out charsWritten, version, format ?? default, formatProvider)) + { + buffer = GC.AllocateUninitializedArray(buffer.Length * 2); + } + + var trackData = new string(buffer[..charsWritten]); + + if (version is null) + { + TrackData = trackData; + } + + return trackData; + } + + public bool TryFormatLegacy(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + return TryFormatLegacy(destination, out charsWritten, version: null, format, provider); + } + +#pragma warning disable IDE0060 + public bool TryFormatLegacy(Span destination, out int charsWritten, int? version, ReadOnlySpan format, IFormatProvider? provider) +#pragma warning restore IDE0060 + { + var buffer = ArrayPool.Shared.Rent(destination.Length); + + try + { + var result = TryEncodeLegacy(buffer, version, out var bytesWritten); + + if (!result) + { + charsWritten = default; + return false; + } + + var operationStatus = Base64.EncodeToUtf8InPlace( + buffer: buffer, + dataLength: bytesWritten, + bytesWritten: out var base64BytesWritten); + + if (operationStatus is not OperationStatus.Done) + { + if (operationStatus is OperationStatus.DestinationTooSmall) + { + charsWritten = default; + return false; + } + + throw new InvalidOperationException("Error while encoding to Base64."); + } + + operationStatus = Utf8.ToUtf16( + source: buffer.AsSpan(0, base64BytesWritten), + destination: destination, + bytesRead: out _, + charsWritten: out charsWritten); + + if (operationStatus is not OperationStatus.Done) + { + if (operationStatus is OperationStatus.DestinationTooSmall) + { + charsWritten = default; + return false; + } + + throw new InvalidOperationException("Error while encoding to UTF-8."); + } + + return true; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + internal bool TryEncodeLegacy(Span buffer, int? version, out int bytesWritten) + { + var versionValue = version ?? 3; + + if (versionValue is not 2 and not 3) + { + throw new ArgumentOutOfRangeException(nameof(version)); + } + + if (SourceName is null) + { + throw new InvalidOperationException("Unknown source."); + } + + var isProbingAudioTrack = IsProbingTrack(SourceName); + + if (isProbingAudioTrack && ProbeInfo is null) + { + throw new InvalidOperationException("For the HTTP and local source audio manager, a probe info must be given."); + } + + if (buffer.Length < 5) + { + bytesWritten = 0; + return false; + } + + // Reserve 5 bytes for the header + var headerBuffer = buffer[..5]; + buffer = buffer[5..]; + bytesWritten = 5; + + // Write title and author + if (!TryEncodeStringLegacy(ref buffer, Title, ref bytesWritten) || + !TryEncodeStringLegacy(ref buffer, Author, ref bytesWritten)) + { + return false; + } + + // Write track duration + if (buffer.Length < 8) + { + return false; + } + + var duration = Duration == TimeSpan.MaxValue + ? long.MaxValue + : (long)Math.Round(Duration.TotalMilliseconds); + + BinaryPrimitives.WriteInt64BigEndian( + destination: buffer[..8], + value: duration); + + buffer = buffer[8..]; + bytesWritten += 8; + + // Write track identifier + if (!TryEncodeStringLegacy(ref buffer, Identifier, ref bytesWritten)) + { + return false; + } + + // Write stream flag + if (buffer.Length < 1) + { + return false; + } + + buffer[0] = (byte)(IsLiveStream ? 1 : 0); + + bytesWritten++; + buffer = buffer[1..]; + + var rawUri = Uri is null ? string.Empty : Uri.ToString(); + + if (!TryEncodeOptionalStringLegacy(ref buffer, rawUri, ref bytesWritten)) + { + return false; + } + + if (versionValue >= 3) + { + var rawArtworkUri = ArtworkUri is null ? string.Empty : ArtworkUri.ToString(); + + if (!TryEncodeOptionalStringLegacy(ref buffer, rawArtworkUri, ref bytesWritten) || + !TryEncodeOptionalStringLegacy(ref buffer, Isrc, ref bytesWritten)) + { + return false; + } + } + + // Write source name + if (!TryEncodeStringLegacy(ref buffer, SourceName, ref bytesWritten)) + { + return false; + } + + // Write probe information + if (isProbingAudioTrack && !TryEncodeStringLegacy(ref buffer, ProbeInfo, ref bytesWritten)) + { + return false; + } + + if (IsExtendedTrack(SourceName)) + { + bool TryEncodeOptionalJsonString(ref Span buffer, string propertyName, ref int bytesWritten) + { + var value = AdditionalInformation.TryGetValue(propertyName, out var jsonElement) + ? jsonElement.GetString()! + : string.Empty; + + return TryEncodeOptionalStringLegacy(ref buffer, value, ref bytesWritten); + } + + if (!TryEncodeOptionalJsonString(ref buffer, "albumName", ref bytesWritten) || + !TryEncodeOptionalJsonString(ref buffer, "albumUrl", ref bytesWritten) || + !TryEncodeOptionalJsonString(ref buffer, "artistUrl", ref bytesWritten) || + !TryEncodeOptionalJsonString(ref buffer, "artistArtworkUrl", ref bytesWritten) || + !TryEncodeOptionalJsonString(ref buffer, "previewUrl", ref bytesWritten)) + { + return false; + } + + var isPreview = AdditionalInformation.TryGetValue("isPreview", out var isPreviewElement) && isPreviewElement.GetBoolean(); + + if (buffer.Length < 1) + { + return false; + } + + buffer[0] = (byte)(isPreview ? 1 : 0); + bytesWritten++; + buffer = buffer[1..]; + } + + // Write track start position + if (buffer.Length < 8) + { + return false; + } + + BinaryPrimitives.WriteInt64BigEndian( + destination: buffer[..8], + value: (long)Math.Round(StartPosition?.TotalMilliseconds ?? 0)); + + // buffer = buffer[8..]; + bytesWritten += 8; + + var payloadLength = bytesWritten - 4; + EncodeHeaderLegacy(headerBuffer, payloadLength, (byte)versionValue); + + return true; + } + + private static void EncodeHeaderLegacy(Span headerBuffer, int payloadLength, byte version) + { + // Set "has version" in header + var header = 0b01000000000000000000000000000000 | payloadLength; + BinaryPrimitives.WriteInt32BigEndian(headerBuffer, header); + + // version + headerBuffer[4] = version; + } + + private static bool TryEncodeStringLegacy(ref Span span, ReadOnlySpan value, ref int bytesWritten) + { + if (span.Length < 2) + { + return false; + } + + var lengthBuffer = span[..2]; + span = span[2..]; + + var previousBytesWritten = bytesWritten; + + if (!TryWriteModifiedUtf8Legacy(ref span, value, ref bytesWritten)) + { + return false; + } + + var utf8BytesWritten = bytesWritten - previousBytesWritten; + + BinaryPrimitives.WriteUInt16BigEndian(lengthBuffer, (ushort)utf8BytesWritten); + + bytesWritten += 2; + + return true; + } + + private static bool TryEncodeOptionalStringLegacy(ref Span span, ReadOnlySpan value, ref int bytesWritten) + { + if (span.Length < 1) + { + return false; + } + + var present = !value.IsWhiteSpace(); + + span[0] = (byte)(present ? 1 : 0); + span = span[1..]; + bytesWritten++; + + if (!present) + { + return true; + } + + if (!TryEncodeStringLegacy(ref span, value, ref bytesWritten)) + { + return false; + } + + return true; + } + + private static bool TryWriteModifiedUtf8Legacy(ref Span span, ReadOnlySpan value, ref int bytesWritten) + { + // Ported from https://android.googlesource.com/platform/prebuilts/fullsdk/sources/android-29/+/refs/heads/androidx-wear-release/java/io/DataOutputStream.java + + int index; + for (index = 0; index < value.Length; index++) + { + var character = value[index]; + + if (character is not (>= (char)0x0001 and <= (char)0x007F)) + { + break; + } + + if (span.IsEmpty) + { + return false; + } + + span[0] = (byte)character; + bytesWritten++; + span = span[1..]; + } + + for (; index < value.Length; index++) + { + var character = value[index]; + + if (character is >= (char)0x0001 and <= (char)0x007F) + { + if (span.IsEmpty) + { + return false; + } + + span[0] = (byte)character; + bytesWritten++; + span = span[1..]; + } + else if (character > 0x07FF) + { + if (span.Length < 3) + { + return false; + } + + span[0] = (byte)(0xE0 | ((character >> 12) & 0x0F)); + span[1] = (byte)(0x80 | ((character >> 6) & 0x3F)); + span[2] = (byte)(0x80 | ((character >> 0) & 0x3F)); + bytesWritten += 3; + span = span[3..]; + } + else + { + if (span.Length < 2) + { + return false; + } + + span[0] = (byte)(0xC0 | ((character >> 6) & 0x1F)); + span[1] = (byte)(0x80 | ((character >> 0) & 0x3F)); + bytesWritten += 2; + span = span[2..]; + } + } + + return true; + } + + #endregion + } +} diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs new file mode 100644 index 00000000..69023a74 --- /dev/null +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs @@ -0,0 +1,20 @@ +namespace Lavalink4NET.Tracks; + +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Buffers.Text; +using System.Text.Unicode; + +public partial record class LavalinkTrack : ISpanFormattable +{ + public string ToString(string? format, IFormatProvider? formatProvider) + { + + } + + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + + } +} diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IParsable.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IParsable.cs new file mode 100644 index 00000000..3d90ee7b --- /dev/null +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IParsable.cs @@ -0,0 +1,37 @@ +namespace Lavalink4NET.Tracks; + +using System; +using System.Buffers; +using System.Buffers.Text; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Unicode; + +public partial record class LavalinkTrack +#if NET7_0_OR_GREATER + : ISpanParsable +#endif +{ + public static LavalinkTrack Parse(ReadOnlySpan s, IFormatProvider? provider) + { + + } + + public static LavalinkTrack Parse(string s, IFormatProvider? provider) + { + + } + + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out LavalinkTrack result) + { + + } + + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out LavalinkTrack result) + { + + } +} diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs new file mode 100644 index 00000000..b6449616 --- /dev/null +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs @@ -0,0 +1,73 @@ +namespace Lavalink4NET.Tracks +{ + public partial record class LavalinkTrack + { + public byte[] Serialize() + { + using MemoryStream memStream = new(); + using BinaryWriter writer = new(memStream); + + // Update this value as this method is updated. + const byte Version = 4; + + if (SourceName is null) + { + throw new InvalidOperationException("Unknown source."); + } + + bool isProbingAudioTrack = IsProbingTrack(SourceName); + + if (isProbingAudioTrack && ProbeInfo is null) + { + throw new InvalidOperationException("For the HTTP and local source audio manager, a probe info must be given."); + } + + string rawUri = Uri?.ToString() ?? string.Empty; + string rawArtworkUri = ArtworkUri?.ToString() ?? string.Empty; + string isrc = Isrc ?? string.Empty; + string probeInfo = ProbeInfo ?? string.Empty; + + long startPosition = (long)Math.Round(StartPosition?.TotalMilliseconds ?? 0); + long duration = Duration == TimeSpan.MaxValue + ? long.MaxValue + : (long)Math.Round(Duration.TotalMilliseconds); + + writer.Write(Version); + writer.Write(Title); + writer.Write(Author); + writer.Write(duration); + writer.Write(Identifier); + writer.Write(IsLiveStream); + writer.Write(rawUri); + writer.Write(rawArtworkUri); + writer.Write(isrc); + writer.Write(SourceName); + writer.Write(probeInfo); + + if (IsExtendedTrack(SourceName)) + { + void WriteJson(string propertyName) + { + string json = AdditionalInformation.TryGetValue(propertyName, out var jsonElement) + ? jsonElement.GetString()! + : string.Empty; + + writer.Write(json); + } + + bool isPreview = AdditionalInformation.TryGetValue("isPreview", out var isPreviewElement) && isPreviewElement.GetBoolean(); + + WriteJson("albumName"); + WriteJson("albumUrl"); + WriteJson("artistUrl"); + WriteJson("artistArtworkUrl"); + WriteJson("previewUrl"); + writer.Write(isPreview); + } + + writer.Write(startPosition); + + return memStream.ToArray(); + } + } +} diff --git a/tests/Lavalink4NET.Abstractions.Tests/LavalinkTrackTests.cs b/tests/Lavalink4NET.Abstractions.Tests/LavalinkTrackTests.cs index 3c934dff..0cf09af3 100644 --- a/tests/Lavalink4NET.Abstractions.Tests/LavalinkTrackTests.cs +++ b/tests/Lavalink4NET.Abstractions.Tests/LavalinkTrackTests.cs @@ -16,11 +16,11 @@ public sealed class LavalinkTrackTests public void TestTrackDecodeEncodeRoundtripV2(string trackIdentifier) { // Arrange - var track = LavalinkTrack.Parse(trackIdentifier, provider: null); + var track = LavalinkTrack.ParseLegacy(trackIdentifier, provider: null); track.TrackData = null; // avoid caching // Act - var actualTrackIdentifier = track.ToString(version: 2); + var actualTrackIdentifier = track.ToStringLegacy(version: 2); // Assert Assert.Equal(trackIdentifier, actualTrackIdentifier); @@ -36,11 +36,11 @@ public void TestTrackDecodeEncodeRoundtripV2(string trackIdentifier) public void TestTrackDecodeEncodeRoundtripV3(string trackIdentifier) { // Arrange - var track = LavalinkTrack.Parse(trackIdentifier, provider: null); + var track = LavalinkTrack.ParseLegacy(trackIdentifier, provider: null); track.TrackData = null; // avoid caching // Act - var actualTrackIdentifier = track.ToString(version: 3); + var actualTrackIdentifier = track.ToStringLegacy(version: 3); // Assert Assert.Equal(trackIdentifier, actualTrackIdentifier); @@ -55,7 +55,7 @@ public void TestTrackDecodeEncodeRoundtripV3(string trackIdentifier) public void TestTrackDecoding(string base64) { // verify the header of the base64 encoded track - var result = LavalinkTrack.TryParse(base64, provider: null, out _); + var result = LavalinkTrack.TryParseLegacy(base64, provider: null, out _); Assert.True(result); } @@ -73,7 +73,7 @@ public void TrackDoesNotThrowOnMissingData(string base64) { for (var size = 0; size < data.Length - 1; size++) { - var result = LavalinkTrack.TryParse(base64, data.AsSpan(0, size), out _); + var result = LavalinkTrack.TryParseLegacy(base64, data.AsSpan(0, size), out _); Assert.False(result); } }); @@ -100,7 +100,7 @@ public void TestTrackDecodeEncodeRoundTripValidate() var track = trackInfo.ToString(); // decode back - var actualTrackInfo = LavalinkTrack.Parse(track, provider: null); + var actualTrackInfo = LavalinkTrack.ParseLegacy(track, provider: null); Assert.Equal(trackInfo.Author, actualTrackInfo.Author); Assert.Equal(trackInfo.Duration, actualTrackInfo.Duration); @@ -136,7 +136,7 @@ public void TestEncodeTrackV3WithIncreasingSize() { for (var size = 0; size < buffer.Length; size++) { - var result = trackInfo.TryEncode( + var result = trackInfo.TryEncodeLegacy( buffer: buffer.AsSpan()[..size], version: 3, bytesWritten: out _); @@ -176,7 +176,7 @@ public void TestEncodeTrackV2WithIncreasingSize() { for (var size = 0; size < buffer.Length; size++) { - var result = trackInfo.TryEncode( + var result = trackInfo.TryEncodeLegacy( buffer: buffer.AsSpan()[..size], version: 2, bytesWritten: out _); @@ -238,7 +238,7 @@ public void TestEncodeWithCachedTrackDataNotCachedWithExplicitVersion(int versio }; // Act - var result = trackInfo.ToString(version); + var result = trackInfo.ToStringLegacy(version); // Assert Assert.NotEqual("cached-id", result); @@ -268,7 +268,7 @@ public void TestEncodeHugeTrack() }; // Act - var result = trackInfo.ToString(format: null, formatProvider: null); + var result = trackInfo.ToStringLegacy(format: null, formatProvider: null); // Assert Assert.NotNull(result); @@ -281,7 +281,7 @@ public void TestDecodeMutateAndEncode() // Arrange // Parsing from a base64 string will cache it internally - var originalTrackInfo = LavalinkTrack.Parse( + var originalTrackInfo = LavalinkTrack.ParseLegacy( s: "QAAAjAIAJFZhbmNlIEpveSAtICdSaXB0aWRlJyBPZmZpY2lhbCBWaWRlbwAObXVzaHJvb212aWRlb3MAAAAAAAMgyAALdUpfMUhNQUdiNGsAAQAraHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj11Sl8xSE1BR2I0awAHeW91dHViZQAAAAAAAAAA", provider: null); @@ -324,7 +324,7 @@ public void TestEncodeKnownTrackWithSpecialCharacters() track.TrackData = null; // avoid caching // Act - var actualIdentifier = track.ToString(version: null); + var actualIdentifier = track.ToStringLegacy(version: null); // Assert Assert.Equal(model.Data, actualIdentifier); @@ -356,7 +356,7 @@ public void TestDecodeKnownTrackWithSpecialCharacters() """)!; // Act - var parsedTrack = LavalinkTrack.Parse(model.Data, provider: null); + var parsedTrack = LavalinkTrack.ParseLegacy(model.Data, provider: null); // Assert Assert.Equal(model.Information.Identifier, parsedTrack.Identifier); @@ -397,7 +397,7 @@ public void TestDecodedTrackEqualsParsedTrack() """)!; // Act - var parsedTrack = LavalinkTrack.Parse(model.Data, provider: null); + var parsedTrack = LavalinkTrack.ParseLegacy(model.Data, provider: null); var decodedTrack = LavalinkApiClient.CreateTrack(model); // Assert @@ -430,7 +430,7 @@ public void TestDecodedTrackHasSameHashCode() """)!; // Act - var parsedTrack = LavalinkTrack.Parse(model.Data, provider: null).GetHashCode(); + var parsedTrack = LavalinkTrack.ParseLegacy(model.Data, provider: null).GetHashCode(); var decodedTrack = LavalinkApiClient.CreateTrack(model).GetHashCode(); // Assert From 9e60f79432aad46096b98e421be265bf7b2b6fcf Mon Sep 17 00:00:00 2001 From: Nycro Date: Wed, 9 Oct 2024 00:06:03 -0400 Subject: [PATCH 2/5] New deserialization --- .../LavalinkTrack.Deserialize.cs | 100 ++++++++++++++++-- .../LavalinkTrack.IFormattable.cs | 29 ++++- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs index 02b3c347..7534f4f0 100644 --- a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs @@ -14,7 +14,91 @@ public partial record class LavalinkTrack { public static LavalinkTrack Deserialize(byte[] data) { + using MemoryStream memStream = new(data); + using BinaryReader reader = new(memStream); + Dictionary additionalInformationBuilder = new(); + + byte version = reader.ReadByte(); + string title = reader.ReadString(); + string author = reader.ReadString(); + long durationMs = reader.ReadInt64(); + string identifier = reader.ReadString(); + bool isLiveStream = reader.ReadBoolean(); + string rawUri = reader.ReadString(); + string rawArtworkUri = reader.ReadString(); + string isrc = reader.ReadString(); + string sourceName = reader.ReadString(); + string probeInfo = reader.ReadString(); + if (IsExtendedTrack(sourceName)) + { + using MemoryStream jsonStream = new(); + using Utf8JsonWriter jsonWriter = new(jsonStream); + JsonObject json = new(); + + void ReadJson(string propertyName) + { + string? propertyValue = reader.ReadString(); + + if (propertyValue == string.Empty) + propertyValue = null; + + // The additional information builder is filled with empty properties, + // which are then assigned after the entire Json object has finished writing. + json.Add(propertyName, propertyValue); + additionalInformationBuilder.Add(propertyName, new()); + } + + ReadJson("albumName"); + ReadJson("albumUrl"); + ReadJson("artistUrl"); + ReadJson("artistArtworkUrl"); + ReadJson("previewUrl"); + + KeyValuePair isPreview = new("isPreview", reader.ReadBoolean()); + json.Add(isPreview); + additionalInformationBuilder.Add(isPreview.Key, new()); + + json.WriteTo(jsonWriter); + var jsonReader = new Utf8JsonReader(jsonStream.ToArray()); + var jsonDocument = JsonElement.ParseValue(ref jsonReader); + + foreach (string property in additionalInformationBuilder.Keys) + { + additionalInformationBuilder[property] = jsonDocument.GetProperty(property); + } + } + + long startPositionMs = reader.ReadInt64(); + + TimeSpan duration = durationMs >= TimeSpan.MaxValue.TotalMilliseconds + ? TimeSpan.MaxValue + : TimeSpan.FromMilliseconds(durationMs); + + TimeSpan? startPosition = startPositionMs is 0 + ? default(TimeSpan?) + : TimeSpan.FromMilliseconds(startPositionMs); + + Uri.TryCreate(rawUri, UriKind.Absolute, out var uri); + Uri.TryCreate(rawArtworkUri, UriKind.Absolute, out var artworkUri); + + return new LavalinkTrack() + { + Author = author, + Identifier = identifier, + Title = title, + Duration = duration, + IsLiveStream = isLiveStream, + IsSeekable = !isLiveStream, + ProbeInfo = probeInfo, + SourceName = sourceName, + StartPosition = startPosition, + Uri = uri, + ArtworkUri = artworkUri, + Isrc = isrc, + AdditionalInformation = additionalInformationBuilder.ToImmutableDictionary(), + TrackData = EncodeDataToUtf16(data) + }; } // These LEGACY regions are a temporary measure in place while I work on re-writing the serialization system. @@ -153,14 +237,14 @@ internal static bool TryParseLegacy(ReadOnlySpan originalTrackData, ref La } var data = new JsonObject - { - {"albumName", albumName }, - {"albumUrl", rawAlbumUri }, - {"artistUrl", rawArtistUri }, - {"artistArtworkUrl", rawArtistArtworkUri }, - {"previewUrl", rawPreviewUri }, - {"isPreview", isPreview }, - }; + { + {"albumName", albumName }, + {"albumUrl", rawAlbumUri }, + {"artistUrl", rawArtistUri }, + {"artistArtworkUrl", rawArtistArtworkUri }, + {"previewUrl", rawPreviewUri }, + {"isPreview", isPreview }, + }; var bufferWriter = new ArrayBufferWriter(); using var utf8JsonWriter = new Utf8JsonWriter(bufferWriter); diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs index 69023a74..d65a2404 100644 --- a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs @@ -2,9 +2,8 @@ using System; using System.Buffers; -using System.Buffers.Binary; using System.Buffers.Text; -using System.Text.Unicode; +using System.Text; public partial record class LavalinkTrack : ISpanFormattable { @@ -17,4 +16,30 @@ public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan { } + + internal static string EncodeDataToUtf16(byte[] data) + { + byte[] buffer = ArrayPool.Shared.Rent(Base64.GetMaxEncodedToUtf8Length(data.Length)); + + try + { + var operationStatus = Base64.EncodeToUtf8( + data, + buffer, + out _, + out int bytesWritten + ); + + if (operationStatus is not OperationStatus.Done) + throw new InvalidOperationException("Error while encoding to Base64."); + + // Gets Utf16 representation + return Encoding.UTF8.GetString(new ArraySegment(buffer, 0, bytesWritten)); + } + + finally + { + ArrayPool.Shared.Return(buffer); + } + } } From 4dd4b7094814e89d66cc6cf6cd07a1cd0c56f145 Mon Sep 17 00:00:00 2001 From: Nycro Date: Wed, 9 Oct 2024 22:09:41 -0400 Subject: [PATCH 3/5] Re-implement legacy serialization --- .../LavalinkTrack.Deserialize.cs | 511 ++---------------- .../LavalinkTrack.IFormattable.cs | 25 +- .../Serialization/LavalinkTrack.IParsable.cs | 70 ++- .../Serialization/LavalinkTrack.Serialize.cs | 315 ++++++++++- .../Entities/Tracks/TrackLoadResult.cs | 6 +- src/Lavalink4NET.Rest/LavalinkApiClient.cs | 1 + .../LavalinkTrackTests.cs | 32 +- 7 files changed, 439 insertions(+), 521 deletions(-) diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs index 7534f4f0..be877e37 100644 --- a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs @@ -1,19 +1,43 @@ -using System.Buffers.Text; -using System.Buffers; -using System.Diagnostics; +using System.Buffers; +using System.Buffers.Binary; using System.Diagnostics.CodeAnalysis; -using System.Text.Unicode; using System.Collections.Immutable; using System.Text.Json.Nodes; using System.Text.Json; -using System.Buffers.Binary; namespace Lavalink4NET.Tracks { public partial record class LavalinkTrack { - public static LavalinkTrack Deserialize(byte[] data) + public static bool TryDeserialize(byte[] data, [MaybeNullWhen(false)] out LavalinkTrack track) => TryDeserialize(data, null, out track); + + public static bool TryDeserialize(byte[] data, string? originalTrackData, [MaybeNullWhen(false)] out LavalinkTrack track) + { + try + { + track = Deserialize(data, originalTrackData); + return true; + } + catch + { + track = null; + return false; + } + } + + public static LavalinkTrack Deserialize(byte[] data, string? originalTrackData = null) { + originalTrackData ??= Utf8ToUtf16(data); + uint header = BinaryPrimitives.ReadUInt32BigEndian(data); + uint size = header & 0x3FFFFFFF; + + // Legacy encoded track!! + if (size == data.Length - 4) + { + if (DeserializeLegacy(originalTrackData, data, out var result)) + return result; + } + using MemoryStream memStream = new(data); using BinaryReader reader = new(memStream); Dictionary additionalInformationBuilder = new(); @@ -97,87 +121,14 @@ void ReadJson(string propertyName) ArtworkUri = artworkUri, Isrc = isrc, AdditionalInformation = additionalInformationBuilder.ToImmutableDictionary(), - TrackData = EncodeDataToUtf16(data) + TrackData = originalTrackData }; } - // These LEGACY regions are a temporary measure in place while I work on re-writing the serialization system. - // The plan is to automatically detect legacy encoding formats and implement these methods within the new decoding system. - // ... but I need to actually write the new decoding system first. - // - // - Nycro, Oct. 7, 2024 - - #region LEGACY PARSING - - public static LavalinkTrack ParseLegacy(string s, IFormatProvider? provider) - { - return ParseLegacy(s.AsSpan(), provider); - } - - public static LavalinkTrack ParseLegacy(ReadOnlySpan s, IFormatProvider? provider) - { - if (!TryParseLegacy(s, provider, out var result)) - { - throw new ArgumentException("Invalid track.", nameof(s)); - } - - return result; - } - - public static bool TryParseLegacy([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out LavalinkTrack result) - { - return TryParseLegacy(s is null ? default : s.AsSpan(), provider, out result); - } - - public static bool TryParseLegacy(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out LavalinkTrack result) - { - var pool = ArrayPool.Shared.Rent(s.Length); - - try - { - var operationStatus = Utf8.FromUtf16( - source: s, - destination: pool, - charsRead: out _, - bytesWritten: out var utf8BytesWritten); - - if (operationStatus is not OperationStatus.Done) - { - Debug.Assert(operationStatus is not OperationStatus.DestinationTooSmall); - - result = null; - return false; - } - - operationStatus = Base64.DecodeFromUtf8InPlace( - buffer: pool.AsSpan(0, utf8BytesWritten), - bytesWritten: out var decodedBytesWritten); - - if (operationStatus is not OperationStatus.Done) - { - Debug.Assert(operationStatus is not OperationStatus.DestinationTooSmall); - - result = null; - return false; - } - - return TryParseLegacy(s, pool.AsSpan(0, decodedBytesWritten), out result); - } - finally - { - ArrayPool.Shared.Return(pool); - } - } - - internal static bool TryParseLegacy(ReadOnlySpan originalTrackData, ReadOnlySpan buffer, [MaybeNullWhen(false)] out LavalinkTrack result) - { - var trackDecoder = new LavalinkTrackDecoder(buffer); - return TryParseLegacy(originalTrackData, ref trackDecoder, out result); - } - - internal static bool TryParseLegacy(ReadOnlySpan originalTrackData, ref LavalinkTrackDecoder trackDecoder, [MaybeNullWhen(false)] out LavalinkTrack result) + private static bool DeserializeLegacy(ReadOnlySpan originalTrackData, ReadOnlySpan buffer, [MaybeNullWhen(false)] out LavalinkTrack result) { result = null; + var trackDecoder = new LegacyLavalinkTrackDecoder(buffer); if (!trackDecoder.TryReadHeader(out var version) || !trackDecoder.TryReadString(out var title) || @@ -296,7 +247,7 @@ internal static bool TryParseLegacy(ReadOnlySpan originalTrackData, ref La return true; } - internal ref struct LavalinkTrackDecoder(ReadOnlySpan buffer) + internal ref struct LegacyLavalinkTrackDecoder(ReadOnlySpan buffer) { public ReadOnlySpan Buffer { get; set; } = buffer; @@ -514,399 +465,5 @@ private static string ReadModifiedUtf8(ReadOnlySpan value) return buffer[..charactersWritten].ToString(); } } - - #endregion - - #region LEGACY FORMATTING - - public string ToStringLegacy() - { - return ToStringLegacy(version: null, format: null, formatProvider: null); - } - - public string ToStringLegacy(string? format, IFormatProvider? formatProvider) - { - return ToStringLegacy(version: null, format: format, formatProvider: formatProvider); - } - - public string ToStringLegacy(int? version) - { - return ToStringLegacy(version: version, format: null, formatProvider: null); - } - - public string ToStringLegacy(int? version, string? format, IFormatProvider? formatProvider) - { - // The ToString method is culture-neutral and format-neutral - if (TrackData is not null && version is null) - { - return TrackData; - } - - Span buffer = stackalloc char[256]; - - int charsWritten; - while (!TryFormatLegacy(buffer, out charsWritten, version, format ?? default, formatProvider)) - { - buffer = GC.AllocateUninitializedArray(buffer.Length * 2); - } - - var trackData = new string(buffer[..charsWritten]); - - if (version is null) - { - TrackData = trackData; - } - - return trackData; - } - - public bool TryFormatLegacy(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) - { - return TryFormatLegacy(destination, out charsWritten, version: null, format, provider); - } - -#pragma warning disable IDE0060 - public bool TryFormatLegacy(Span destination, out int charsWritten, int? version, ReadOnlySpan format, IFormatProvider? provider) -#pragma warning restore IDE0060 - { - var buffer = ArrayPool.Shared.Rent(destination.Length); - - try - { - var result = TryEncodeLegacy(buffer, version, out var bytesWritten); - - if (!result) - { - charsWritten = default; - return false; - } - - var operationStatus = Base64.EncodeToUtf8InPlace( - buffer: buffer, - dataLength: bytesWritten, - bytesWritten: out var base64BytesWritten); - - if (operationStatus is not OperationStatus.Done) - { - if (operationStatus is OperationStatus.DestinationTooSmall) - { - charsWritten = default; - return false; - } - - throw new InvalidOperationException("Error while encoding to Base64."); - } - - operationStatus = Utf8.ToUtf16( - source: buffer.AsSpan(0, base64BytesWritten), - destination: destination, - bytesRead: out _, - charsWritten: out charsWritten); - - if (operationStatus is not OperationStatus.Done) - { - if (operationStatus is OperationStatus.DestinationTooSmall) - { - charsWritten = default; - return false; - } - - throw new InvalidOperationException("Error while encoding to UTF-8."); - } - - return true; - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - internal bool TryEncodeLegacy(Span buffer, int? version, out int bytesWritten) - { - var versionValue = version ?? 3; - - if (versionValue is not 2 and not 3) - { - throw new ArgumentOutOfRangeException(nameof(version)); - } - - if (SourceName is null) - { - throw new InvalidOperationException("Unknown source."); - } - - var isProbingAudioTrack = IsProbingTrack(SourceName); - - if (isProbingAudioTrack && ProbeInfo is null) - { - throw new InvalidOperationException("For the HTTP and local source audio manager, a probe info must be given."); - } - - if (buffer.Length < 5) - { - bytesWritten = 0; - return false; - } - - // Reserve 5 bytes for the header - var headerBuffer = buffer[..5]; - buffer = buffer[5..]; - bytesWritten = 5; - - // Write title and author - if (!TryEncodeStringLegacy(ref buffer, Title, ref bytesWritten) || - !TryEncodeStringLegacy(ref buffer, Author, ref bytesWritten)) - { - return false; - } - - // Write track duration - if (buffer.Length < 8) - { - return false; - } - - var duration = Duration == TimeSpan.MaxValue - ? long.MaxValue - : (long)Math.Round(Duration.TotalMilliseconds); - - BinaryPrimitives.WriteInt64BigEndian( - destination: buffer[..8], - value: duration); - - buffer = buffer[8..]; - bytesWritten += 8; - - // Write track identifier - if (!TryEncodeStringLegacy(ref buffer, Identifier, ref bytesWritten)) - { - return false; - } - - // Write stream flag - if (buffer.Length < 1) - { - return false; - } - - buffer[0] = (byte)(IsLiveStream ? 1 : 0); - - bytesWritten++; - buffer = buffer[1..]; - - var rawUri = Uri is null ? string.Empty : Uri.ToString(); - - if (!TryEncodeOptionalStringLegacy(ref buffer, rawUri, ref bytesWritten)) - { - return false; - } - - if (versionValue >= 3) - { - var rawArtworkUri = ArtworkUri is null ? string.Empty : ArtworkUri.ToString(); - - if (!TryEncodeOptionalStringLegacy(ref buffer, rawArtworkUri, ref bytesWritten) || - !TryEncodeOptionalStringLegacy(ref buffer, Isrc, ref bytesWritten)) - { - return false; - } - } - - // Write source name - if (!TryEncodeStringLegacy(ref buffer, SourceName, ref bytesWritten)) - { - return false; - } - - // Write probe information - if (isProbingAudioTrack && !TryEncodeStringLegacy(ref buffer, ProbeInfo, ref bytesWritten)) - { - return false; - } - - if (IsExtendedTrack(SourceName)) - { - bool TryEncodeOptionalJsonString(ref Span buffer, string propertyName, ref int bytesWritten) - { - var value = AdditionalInformation.TryGetValue(propertyName, out var jsonElement) - ? jsonElement.GetString()! - : string.Empty; - - return TryEncodeOptionalStringLegacy(ref buffer, value, ref bytesWritten); - } - - if (!TryEncodeOptionalJsonString(ref buffer, "albumName", ref bytesWritten) || - !TryEncodeOptionalJsonString(ref buffer, "albumUrl", ref bytesWritten) || - !TryEncodeOptionalJsonString(ref buffer, "artistUrl", ref bytesWritten) || - !TryEncodeOptionalJsonString(ref buffer, "artistArtworkUrl", ref bytesWritten) || - !TryEncodeOptionalJsonString(ref buffer, "previewUrl", ref bytesWritten)) - { - return false; - } - - var isPreview = AdditionalInformation.TryGetValue("isPreview", out var isPreviewElement) && isPreviewElement.GetBoolean(); - - if (buffer.Length < 1) - { - return false; - } - - buffer[0] = (byte)(isPreview ? 1 : 0); - bytesWritten++; - buffer = buffer[1..]; - } - - // Write track start position - if (buffer.Length < 8) - { - return false; - } - - BinaryPrimitives.WriteInt64BigEndian( - destination: buffer[..8], - value: (long)Math.Round(StartPosition?.TotalMilliseconds ?? 0)); - - // buffer = buffer[8..]; - bytesWritten += 8; - - var payloadLength = bytesWritten - 4; - EncodeHeaderLegacy(headerBuffer, payloadLength, (byte)versionValue); - - return true; - } - - private static void EncodeHeaderLegacy(Span headerBuffer, int payloadLength, byte version) - { - // Set "has version" in header - var header = 0b01000000000000000000000000000000 | payloadLength; - BinaryPrimitives.WriteInt32BigEndian(headerBuffer, header); - - // version - headerBuffer[4] = version; - } - - private static bool TryEncodeStringLegacy(ref Span span, ReadOnlySpan value, ref int bytesWritten) - { - if (span.Length < 2) - { - return false; - } - - var lengthBuffer = span[..2]; - span = span[2..]; - - var previousBytesWritten = bytesWritten; - - if (!TryWriteModifiedUtf8Legacy(ref span, value, ref bytesWritten)) - { - return false; - } - - var utf8BytesWritten = bytesWritten - previousBytesWritten; - - BinaryPrimitives.WriteUInt16BigEndian(lengthBuffer, (ushort)utf8BytesWritten); - - bytesWritten += 2; - - return true; - } - - private static bool TryEncodeOptionalStringLegacy(ref Span span, ReadOnlySpan value, ref int bytesWritten) - { - if (span.Length < 1) - { - return false; - } - - var present = !value.IsWhiteSpace(); - - span[0] = (byte)(present ? 1 : 0); - span = span[1..]; - bytesWritten++; - - if (!present) - { - return true; - } - - if (!TryEncodeStringLegacy(ref span, value, ref bytesWritten)) - { - return false; - } - - return true; - } - - private static bool TryWriteModifiedUtf8Legacy(ref Span span, ReadOnlySpan value, ref int bytesWritten) - { - // Ported from https://android.googlesource.com/platform/prebuilts/fullsdk/sources/android-29/+/refs/heads/androidx-wear-release/java/io/DataOutputStream.java - - int index; - for (index = 0; index < value.Length; index++) - { - var character = value[index]; - - if (character is not (>= (char)0x0001 and <= (char)0x007F)) - { - break; - } - - if (span.IsEmpty) - { - return false; - } - - span[0] = (byte)character; - bytesWritten++; - span = span[1..]; - } - - for (; index < value.Length; index++) - { - var character = value[index]; - - if (character is >= (char)0x0001 and <= (char)0x007F) - { - if (span.IsEmpty) - { - return false; - } - - span[0] = (byte)character; - bytesWritten++; - span = span[1..]; - } - else if (character > 0x07FF) - { - if (span.Length < 3) - { - return false; - } - - span[0] = (byte)(0xE0 | ((character >> 12) & 0x0F)); - span[1] = (byte)(0x80 | ((character >> 6) & 0x3F)); - span[2] = (byte)(0x80 | ((character >> 0) & 0x3F)); - bytesWritten += 3; - span = span[3..]; - } - else - { - if (span.Length < 2) - { - return false; - } - - span[0] = (byte)(0xC0 | ((character >> 6) & 0x1F)); - span[1] = (byte)(0x80 | ((character >> 0) & 0x3F)); - bytesWritten += 2; - span = span[2..]; - } - } - - return true; - } - - #endregion } } diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs index d65a2404..d87e0be6 100644 --- a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs @@ -7,17 +7,30 @@ public partial record class LavalinkTrack : ISpanFormattable { - public string ToString(string? format, IFormatProvider? formatProvider) - { - - } + public string ToString(int? version) => Utf8ToUtf16(Serialize(version)); + + public override string ToString() => ToString(version: null); + + public string ToString(string? format, IFormatProvider? formatProvider) => ToString(); public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) { - + try + { + string data = ToString(); + data.CopyTo(destination); + charsWritten = data.Length; + } + catch + { + charsWritten = 0; + return false; + } + + return true; } - internal static string EncodeDataToUtf16(byte[] data) + internal static string Utf8ToUtf16(byte[] data) { byte[] buffer = ArrayPool.Shared.Rent(Base64.GetMaxEncodedToUtf8Length(data.Length)); diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IParsable.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IParsable.cs index 3d90ee7b..80786758 100644 --- a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IParsable.cs +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IParsable.cs @@ -3,35 +3,75 @@ using System; using System.Buffers; using System.Buffers.Text; -using System.Collections.Immutable; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Unicode; +using System.Text; public partial record class LavalinkTrack #if NET7_0_OR_GREATER : ISpanParsable #endif { - public static LavalinkTrack Parse(ReadOnlySpan s, IFormatProvider? provider) - { - - } + public static LavalinkTrack Parse(ReadOnlySpan s, IFormatProvider? provider) => Parse(s.ToString(), provider); + + public static LavalinkTrack Parse(ReadOnlySpan s) => Parse(s, null); - public static LavalinkTrack Parse(string s, IFormatProvider? provider) + public static LavalinkTrack Parse(string s, IFormatProvider? provider) => Deserialize(Utf16ToUtf8(s), s); + + public static LavalinkTrack Parse(string s) => Parse(s, null); + + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out LavalinkTrack result) { - + try + { + string data = s.ToString(); + return TryParse(data, provider, out result); + } + catch + { + result = null; + return false; + } } - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out LavalinkTrack result) + public static bool TryParse(string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out LavalinkTrack result) { - + try + { + // Although this can be null, the try catch makes that irrelevant. + // Indicating it can't be null silences the code analytics. + result = Parse(s!, provider); + return true; + } + + catch + { + result = null; + return false; + } } - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out LavalinkTrack result) + internal static byte[] Utf16ToUtf8(string data) { - + byte[] buffer = Encoding.UTF8.GetBytes(data); + byte[] result = ArrayPool.Shared.Rent(Base64.GetMaxDecodedFromUtf8Length(buffer.Length)); + + try + { + var operationStatus = Base64.DecodeFromUtf8( + buffer, + result, + out _, + out int bytesWritten + ); + + if (operationStatus is not OperationStatus.Done) + throw new InvalidOperationException("Error while decoding from Base64."); + + return new ArraySegment(result, 0, bytesWritten).ToArray(); + } + finally + { + ArrayPool.Shared.Return(result); + } } } diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs index b6449616..49610a5a 100644 --- a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs @@ -1,14 +1,21 @@ -namespace Lavalink4NET.Tracks +using System.Buffers.Binary; + +namespace Lavalink4NET.Tracks { public partial record class LavalinkTrack { - public byte[] Serialize() + public byte[] Serialize(int? version = null) { using MemoryStream memStream = new(); using BinaryWriter writer = new(memStream); // Update this value as this method is updated. - const byte Version = 4; + version ??= 4; + + // Do NOT update this value as this method is updated. + // This indicates legacy tracks. + if (version < 4) + return SerializeLegacy(version.Value); if (SourceName is null) { @@ -32,7 +39,7 @@ public byte[] Serialize() ? long.MaxValue : (long)Math.Round(Duration.TotalMilliseconds); - writer.Write(Version); + writer.Write(version.Value); writer.Write(Title); writer.Write(Author); writer.Write(duration); @@ -69,5 +76,305 @@ void WriteJson(string propertyName) return memStream.ToArray(); } + + private byte[] SerializeLegacy(int version) + { + Span buffer = stackalloc byte[256]; + + int bytesWritten; + while (!TryEncodeLegacy(buffer, version, out bytesWritten)) + { + buffer = GC.AllocateUninitializedArray(buffer.Length * 2); + } + + return buffer[..bytesWritten].ToArray(); + } + + #region LEGACY ENCODING + + internal bool TryEncodeLegacy(Span buffer, int version, out int bytesWritten) + { + if (version is not 2 and not 3) + { + throw new ArgumentOutOfRangeException(nameof(version)); + } + + if (SourceName is null) + { + throw new InvalidOperationException("Unknown source."); + } + + var isProbingAudioTrack = IsProbingTrack(SourceName); + + if (isProbingAudioTrack && ProbeInfo is null) + { + throw new InvalidOperationException("For the HTTP and local source audio manager, a probe info must be given."); + } + + if (buffer.Length < 5) + { + bytesWritten = 0; + return false; + } + + // Reserve 5 bytes for the header + var headerBuffer = buffer[..5]; + buffer = buffer[5..]; + bytesWritten = 5; + + // Write title and author + if (!TryEncodeStringLegacy(ref buffer, Title, ref bytesWritten) || + !TryEncodeStringLegacy(ref buffer, Author, ref bytesWritten)) + { + return false; + } + + // Write track duration + if (buffer.Length < 8) + { + return false; + } + + var duration = Duration == TimeSpan.MaxValue + ? long.MaxValue + : (long)Math.Round(Duration.TotalMilliseconds); + + BinaryPrimitives.WriteInt64BigEndian( + destination: buffer[..8], + value: duration); + + buffer = buffer[8..]; + bytesWritten += 8; + + // Write track identifier + if (!TryEncodeStringLegacy(ref buffer, Identifier, ref bytesWritten)) + { + return false; + } + + // Write stream flag + if (buffer.Length < 1) + { + return false; + } + + buffer[0] = (byte)(IsLiveStream ? 1 : 0); + + bytesWritten++; + buffer = buffer[1..]; + + var rawUri = Uri is null ? string.Empty : Uri.ToString(); + + if (!TryEncodeOptionalStringLegacy(ref buffer, rawUri, ref bytesWritten)) + { + return false; + } + + if (version >= 3) + { + var rawArtworkUri = ArtworkUri is null ? string.Empty : ArtworkUri.ToString(); + + if (!TryEncodeOptionalStringLegacy(ref buffer, rawArtworkUri, ref bytesWritten) || + !TryEncodeOptionalStringLegacy(ref buffer, Isrc, ref bytesWritten)) + { + return false; + } + } + + // Write source name + if (!TryEncodeStringLegacy(ref buffer, SourceName, ref bytesWritten)) + { + return false; + } + + // Write probe information + if (isProbingAudioTrack && !TryEncodeStringLegacy(ref buffer, ProbeInfo, ref bytesWritten)) + { + return false; + } + + if (IsExtendedTrack(SourceName)) + { + bool TryEncodeOptionalJsonString(ref Span buffer, string propertyName, ref int bytesWritten) + { + var value = AdditionalInformation.TryGetValue(propertyName, out var jsonElement) + ? jsonElement.GetString()! + : string.Empty; + + return TryEncodeOptionalStringLegacy(ref buffer, value, ref bytesWritten); + } + + if (!TryEncodeOptionalJsonString(ref buffer, "albumName", ref bytesWritten) || + !TryEncodeOptionalJsonString(ref buffer, "albumUrl", ref bytesWritten) || + !TryEncodeOptionalJsonString(ref buffer, "artistUrl", ref bytesWritten) || + !TryEncodeOptionalJsonString(ref buffer, "artistArtworkUrl", ref bytesWritten) || + !TryEncodeOptionalJsonString(ref buffer, "previewUrl", ref bytesWritten)) + { + return false; + } + + var isPreview = AdditionalInformation.TryGetValue("isPreview", out var isPreviewElement) && isPreviewElement.GetBoolean(); + + if (buffer.Length < 1) + { + return false; + } + + buffer[0] = (byte)(isPreview ? 1 : 0); + bytesWritten++; + buffer = buffer[1..]; + } + + // Write track start position + if (buffer.Length < 8) + { + return false; + } + + BinaryPrimitives.WriteInt64BigEndian( + destination: buffer[..8], + value: (long)Math.Round(StartPosition?.TotalMilliseconds ?? 0)); + + // buffer = buffer[8..]; + bytesWritten += 8; + + var payloadLength = bytesWritten - 4; + EncodeHeaderLegacy(headerBuffer, payloadLength, (byte)version); + + return true; + } + + private static void EncodeHeaderLegacy(Span headerBuffer, int payloadLength, byte version) + { + // Set "has version" in header + var header = 0b01000000000000000000000000000000 | payloadLength; + BinaryPrimitives.WriteInt32BigEndian(headerBuffer, header); + + // version + headerBuffer[4] = version; + } + + private static bool TryEncodeStringLegacy(ref Span span, ReadOnlySpan value, ref int bytesWritten) + { + if (span.Length < 2) + { + return false; + } + + var lengthBuffer = span[..2]; + span = span[2..]; + + var previousBytesWritten = bytesWritten; + + if (!TryWriteModifiedUtf8Legacy(ref span, value, ref bytesWritten)) + { + return false; + } + + var utf8BytesWritten = bytesWritten - previousBytesWritten; + + BinaryPrimitives.WriteUInt16BigEndian(lengthBuffer, (ushort)utf8BytesWritten); + + bytesWritten += 2; + + return true; + } + + private static bool TryEncodeOptionalStringLegacy(ref Span span, ReadOnlySpan value, ref int bytesWritten) + { + if (span.Length < 1) + { + return false; + } + + var present = !value.IsWhiteSpace(); + + span[0] = (byte)(present ? 1 : 0); + span = span[1..]; + bytesWritten++; + + if (!present) + { + return true; + } + + if (!TryEncodeStringLegacy(ref span, value, ref bytesWritten)) + { + return false; + } + + return true; + } + + private static bool TryWriteModifiedUtf8Legacy(ref Span span, ReadOnlySpan value, ref int bytesWritten) + { + // Ported from https://android.googlesource.com/platform/prebuilts/fullsdk/sources/android-29/+/refs/heads/androidx-wear-release/java/io/DataOutputStream.java + + int index; + for (index = 0; index < value.Length; index++) + { + var character = value[index]; + + if (character is not (>= (char)0x0001 and <= (char)0x007F)) + { + break; + } + + if (span.IsEmpty) + { + return false; + } + + span[0] = (byte)character; + bytesWritten++; + span = span[1..]; + } + + for (; index < value.Length; index++) + { + var character = value[index]; + + if (character is >= (char)0x0001 and <= (char)0x007F) + { + if (span.IsEmpty) + { + return false; + } + + span[0] = (byte)character; + bytesWritten++; + span = span[1..]; + } + else if (character > 0x07FF) + { + if (span.Length < 3) + { + return false; + } + + span[0] = (byte)(0xE0 | ((character >> 12) & 0x0F)); + span[1] = (byte)(0x80 | ((character >> 6) & 0x3F)); + span[2] = (byte)(0x80 | ((character >> 0) & 0x3F)); + bytesWritten += 3; + span = span[3..]; + } + else + { + if (span.Length < 2) + { + return false; + } + + span[0] = (byte)(0xC0 | ((character >> 6) & 0x1F)); + span[1] = (byte)(0x80 | ((character >> 0) & 0x3F)); + bytesWritten += 2; + span = span[2..]; + } + } + + return true; + } + + #endregion } } diff --git a/src/Lavalink4NET.Rest/Entities/Tracks/TrackLoadResult.cs b/src/Lavalink4NET.Rest/Entities/Tracks/TrackLoadResult.cs index 8d5627c0..eec336b0 100644 --- a/src/Lavalink4NET.Rest/Entities/Tracks/TrackLoadResult.cs +++ b/src/Lavalink4NET.Rest/Entities/Tracks/TrackLoadResult.cs @@ -9,9 +9,9 @@ public readonly record struct TrackLoadResult { private readonly object? _value; // either LavalinkTrack[] (immutable!), LavalinkTrack, or ExceptionData, null (no matches) - private readonly PlaylistInformation _playlist; + private readonly PlaylistInformation? _playlist; - public TrackLoadResult(object? value, PlaylistInformation playlist) + public TrackLoadResult(object? value, PlaylistInformation? playlist) { _value = value; _playlist = playlist; @@ -42,7 +42,7 @@ public TrackLoadResult(object? value, PlaylistInformation playlist) public ImmutableArray Tracks => _value switch { LavalinkTrack track => ImmutableArray.Create(track), - LavalinkTrack[] tracks => Unsafe.As>(ref Unsafe.AsRef(tracks)), + LavalinkTrack[] tracks => Unsafe.As>(ref Unsafe.AsRef(in tracks)), _ => ImmutableArray.Empty, }; diff --git a/src/Lavalink4NET.Rest/LavalinkApiClient.cs b/src/Lavalink4NET.Rest/LavalinkApiClient.cs index 6cb5d5fc..76ede9ad 100644 --- a/src/Lavalink4NET.Rest/LavalinkApiClient.cs +++ b/src/Lavalink4NET.Rest/LavalinkApiClient.cs @@ -445,6 +445,7 @@ internal static class StrictSearchHelper StrictSearchBehavior.Implicit => ProcessImplicit(identifier, searchMode), StrictSearchBehavior.Explicit => ProcessExplicit(identifier, searchMode), StrictSearchBehavior.Passthrough => ProcessPassthrough(identifier, searchMode), + _ => throw new NotImplementedException() }; private static string ProcessThrow(string identifier, TrackSearchMode searchMode) diff --git a/tests/Lavalink4NET.Abstractions.Tests/LavalinkTrackTests.cs b/tests/Lavalink4NET.Abstractions.Tests/LavalinkTrackTests.cs index 0cf09af3..12cc2fad 100644 --- a/tests/Lavalink4NET.Abstractions.Tests/LavalinkTrackTests.cs +++ b/tests/Lavalink4NET.Abstractions.Tests/LavalinkTrackTests.cs @@ -16,11 +16,11 @@ public sealed class LavalinkTrackTests public void TestTrackDecodeEncodeRoundtripV2(string trackIdentifier) { // Arrange - var track = LavalinkTrack.ParseLegacy(trackIdentifier, provider: null); + var track = LavalinkTrack.Parse(trackIdentifier, provider: null); track.TrackData = null; // avoid caching // Act - var actualTrackIdentifier = track.ToStringLegacy(version: 2); + var actualTrackIdentifier = track.ToString(version: 2); // Assert Assert.Equal(trackIdentifier, actualTrackIdentifier); @@ -36,11 +36,11 @@ public void TestTrackDecodeEncodeRoundtripV2(string trackIdentifier) public void TestTrackDecodeEncodeRoundtripV3(string trackIdentifier) { // Arrange - var track = LavalinkTrack.ParseLegacy(trackIdentifier, provider: null); + var track = LavalinkTrack.Parse(trackIdentifier, provider: null); track.TrackData = null; // avoid caching // Act - var actualTrackIdentifier = track.ToStringLegacy(version: 3); + var actualTrackIdentifier = track.ToString(version: 3); // Assert Assert.Equal(trackIdentifier, actualTrackIdentifier); @@ -55,7 +55,7 @@ public void TestTrackDecodeEncodeRoundtripV3(string trackIdentifier) public void TestTrackDecoding(string base64) { // verify the header of the base64 encoded track - var result = LavalinkTrack.TryParseLegacy(base64, provider: null, out _); + var result = LavalinkTrack.TryParse(base64, provider: null, out _); Assert.True(result); } @@ -73,7 +73,7 @@ public void TrackDoesNotThrowOnMissingData(string base64) { for (var size = 0; size < data.Length - 1; size++) { - var result = LavalinkTrack.TryParseLegacy(base64, data.AsSpan(0, size), out _); + var result = LavalinkTrack.TryDeserialize(data.AsSpan(0, size).ToArray(), base64, out _); Assert.False(result); } }); @@ -100,7 +100,7 @@ public void TestTrackDecodeEncodeRoundTripValidate() var track = trackInfo.ToString(); // decode back - var actualTrackInfo = LavalinkTrack.ParseLegacy(track, provider: null); + var actualTrackInfo = LavalinkTrack.Parse(track); Assert.Equal(trackInfo.Author, actualTrackInfo.Author); Assert.Equal(trackInfo.Duration, actualTrackInfo.Duration); @@ -238,7 +238,7 @@ public void TestEncodeWithCachedTrackDataNotCachedWithExplicitVersion(int versio }; // Act - var result = trackInfo.ToStringLegacy(version); + var result = trackInfo.ToString(version: version); // Assert Assert.NotEqual("cached-id", result); @@ -268,7 +268,7 @@ public void TestEncodeHugeTrack() }; // Act - var result = trackInfo.ToStringLegacy(format: null, formatProvider: null); + var result = trackInfo.ToString(); // Assert Assert.NotNull(result); @@ -281,9 +281,9 @@ public void TestDecodeMutateAndEncode() // Arrange // Parsing from a base64 string will cache it internally - var originalTrackInfo = LavalinkTrack.ParseLegacy( - s: "QAAAjAIAJFZhbmNlIEpveSAtICdSaXB0aWRlJyBPZmZpY2lhbCBWaWRlbwAObXVzaHJvb212aWRlb3MAAAAAAAMgyAALdUpfMUhNQUdiNGsAAQAraHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj11Sl8xSE1BR2I0awAHeW91dHViZQAAAAAAAAAA", - provider: null); + var originalTrackInfo = LavalinkTrack.Parse( + "QAAAjAIAJFZhbmNlIEpveSAtICdSaXB0aWRlJyBPZmZpY2lhbCBWaWRlbwAObXVzaHJvb212aWRlb3MAAAAAAAMgyAALdUpfMUhNQUdiNGsAAQAraHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj11Sl8xSE1BR2I0awAHeW91dHViZQAAAAAAAAAA" + ); // Mutate track var mutatedTrack = originalTrackInfo with { StartPosition = TimeSpan.FromSeconds(30), }; @@ -324,7 +324,7 @@ public void TestEncodeKnownTrackWithSpecialCharacters() track.TrackData = null; // avoid caching // Act - var actualIdentifier = track.ToStringLegacy(version: null); + var actualIdentifier = track.ToString(); // Assert Assert.Equal(model.Data, actualIdentifier); @@ -356,7 +356,7 @@ public void TestDecodeKnownTrackWithSpecialCharacters() """)!; // Act - var parsedTrack = LavalinkTrack.ParseLegacy(model.Data, provider: null); + var parsedTrack = LavalinkTrack.Parse(model.Data); // Assert Assert.Equal(model.Information.Identifier, parsedTrack.Identifier); @@ -397,7 +397,7 @@ public void TestDecodedTrackEqualsParsedTrack() """)!; // Act - var parsedTrack = LavalinkTrack.ParseLegacy(model.Data, provider: null); + var parsedTrack = LavalinkTrack.Parse(model.Data); var decodedTrack = LavalinkApiClient.CreateTrack(model); // Assert @@ -430,7 +430,7 @@ public void TestDecodedTrackHasSameHashCode() """)!; // Act - var parsedTrack = LavalinkTrack.ParseLegacy(model.Data, provider: null).GetHashCode(); + var parsedTrack = LavalinkTrack.Parse(model.Data).GetHashCode(); var decodedTrack = LavalinkApiClient.CreateTrack(model).GetHashCode(); // Assert From b3e4120d2dfa9502270ba49f6373d3a2439fe517 Mon Sep 17 00:00:00 2001 From: Nycro Date: Wed, 9 Oct 2024 22:29:44 -0400 Subject: [PATCH 4/5] Documentation --- .../LavalinkTrack.Deserialize.cs | 22 +++++++++++-------- .../LavalinkTrack.IFormattable.cs | 4 ++-- .../Serialization/LavalinkTrack.Serialize.cs | 11 ++++++++++ 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs index be877e37..2e9024e0 100644 --- a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Deserialize.cs @@ -32,16 +32,16 @@ public static LavalinkTrack Deserialize(byte[] data, string? originalTrackData = uint size = header & 0x3FFFFFFF; // Legacy encoded track!! - if (size == data.Length - 4) - { - if (DeserializeLegacy(originalTrackData, data, out var result)) - return result; - } + if (size == data.Length - 4 && DeserializeLegacy(originalTrackData, data, out var result)) + return result; using MemoryStream memStream = new(data); using BinaryReader reader = new(memStream); Dictionary additionalInformationBuilder = new(); + // The following fields are encoded in order using + // a BinaryWriter for writing and BinaryReader for reading. + byte version = reader.ReadByte(); string title = reader.ReadString(); string author = reader.ReadString(); @@ -79,10 +79,14 @@ void ReadJson(string propertyName) ReadJson("artistArtworkUrl"); ReadJson("previewUrl"); + // "isPreview" is special, as it is encoded as a single byte, + // not as a string. KeyValuePair isPreview = new("isPreview", reader.ReadBoolean()); json.Add(isPreview); additionalInformationBuilder.Add(isPreview.Key, new()); + // Writing the json object and then reading it + // allows for the transition to JsonElement based properties. json.WriteTo(jsonWriter); var jsonReader = new Utf8JsonReader(jsonStream.ToArray()); var jsonDocument = JsonElement.ParseValue(ref jsonReader); @@ -96,12 +100,12 @@ void ReadJson(string propertyName) long startPositionMs = reader.ReadInt64(); TimeSpan duration = durationMs >= TimeSpan.MaxValue.TotalMilliseconds - ? TimeSpan.MaxValue - : TimeSpan.FromMilliseconds(durationMs); + ? TimeSpan.MaxValue + : TimeSpan.FromMilliseconds(durationMs); TimeSpan? startPosition = startPositionMs is 0 - ? default(TimeSpan?) - : TimeSpan.FromMilliseconds(startPositionMs); + ? default(TimeSpan?) + : TimeSpan.FromMilliseconds(startPositionMs); Uri.TryCreate(rawUri, UriKind.Absolute, out var uri); Uri.TryCreate(rawArtworkUri, UriKind.Absolute, out var artworkUri); diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs index d87e0be6..227078ec 100644 --- a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs @@ -7,11 +7,11 @@ public partial record class LavalinkTrack : ISpanFormattable { - public string ToString(int? version) => Utf8ToUtf16(Serialize(version)); + public string ToString(string? format, IFormatProvider? formatProvider) => ToString(); public override string ToString() => ToString(version: null); - public string ToString(string? format, IFormatProvider? formatProvider) => ToString(); + public string ToString(int? version) => Utf8ToUtf16(Serialize(version)); public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) { diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs index 49610a5a..0b33443b 100644 --- a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs @@ -17,6 +17,17 @@ public byte[] Serialize(int? version = null) if (version < 4) return SerializeLegacy(version.Value); + // The serialization structure that follows is largely the same as legacy tracks. + // + // However, among a few other slight improvements (like removing the need to repeatedly + // re-allocate buffers to ensure a container is large enough), the C# BinaryReader/BinaryWriter + // use little-endian format, whereas the original structure uses big-endian - so they are incompatible. + // + // While there were 0 problems with the original system, and it worked perfectly fine, the positive benefits that come + // with switching to C# binary encoding well outweigh any that would come from staying. + // It is easier to expand, standardized, requires less extensions, natively integrated, optimized, and most of all, much cleaner. + // It additionally allows for dynamic buffer sizing, as it is capable of writing directly to a MemoryStream. + if (SourceName is null) { throw new InvalidOperationException("Unknown source."); From 2f6d97c958ddf0cf9e0b8adf8167d27d876fd79c Mon Sep 17 00:00:00 2001 From: NycroV <83246959+NycroV@users.noreply.github.com> Date: Sun, 13 Oct 2024 00:13:28 -0400 Subject: [PATCH 5/5] Fix tests* --- .../Tracks/Serialization/LavalinkTrack.IFormattable.cs | 8 +++++++- .../Tracks/Serialization/LavalinkTrack.Serialize.cs | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs index 227078ec..313e9c52 100644 --- a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.IFormattable.cs @@ -11,7 +11,13 @@ public partial record class LavalinkTrack : ISpanFormattable public override string ToString() => ToString(version: null); - public string ToString(int? version) => Utf8ToUtf16(Serialize(version)); + public string ToString(int? version) + { + if (TrackData is null || version is not null) + TrackData = Utf8ToUtf16(Serialize(version)); + + return TrackData; + } public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) { diff --git a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs index 0b33443b..d1efd544 100644 --- a/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs +++ b/src/Lavalink4NET.Abstractions/Tracks/Serialization/LavalinkTrack.Serialize.cs @@ -50,7 +50,7 @@ public byte[] Serialize(int? version = null) ? long.MaxValue : (long)Math.Round(Duration.TotalMilliseconds); - writer.Write(version.Value); + writer.Write((byte)version.Value); writer.Write(Title); writer.Write(Author); writer.Write(duration);