Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Moq;
using Moq.Protected;
using Smoc.Configuration;
using Smoc.Streaming;
using Smoc.Streaming.Tidal;
using Smoc.Streaming.Tidal.Models;
using Smoc.Services.Caching;

namespace smoc.Tests.Streaming.Tidal;

public class TidalStreamingClientServiceTest {
private static (TidalStreamingClient, Mock<HttpMessageHandler>) CreateClientWithMockResponse<T>(T responseObj, HttpStatusCode statusCode = HttpStatusCode.OK) {
var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage {
StatusCode = statusCode,
Content = JsonContent.Create(responseObj),
});

var httpClient = new HttpClient(handlerMock.Object);
var client = TidalStreamingClient.CreateForTesting(httpClient);
return (client, handlerMock);
}

[Fact]
public async Task SearchSongsAsync_ReturnsSongs() {
var artist = new TidalArtist(10, "Artist 1", null);
var album = new TidalAlbum(100, "Album 1", "cover-id", "2023", artist);
var track = new TidalTrack(1, "Track 1", 180, 1, album, artist, [artist]);
var response = new TidalSearchContainer(Tracks: new TidalSearchResponse<TidalTrack>([track], 1));

var (client, _) = CreateClientWithMockResponse(response);

var results = await client.SearchSongsAsync("query", TestContext.Current.CancellationToken);

Assert.Single(results);
Assert.Equal("Track 1", results[0].Title);
Assert.Equal("1", results[0].Id);
Assert.Equal("Artist 1", results[0].Artist.Name);
Assert.Equal("https://resources.tidal.com/images/cover/id/640x640.jpg", results[0].Album.Covers.First().Url);
}

[Fact]
public async Task GetSongStreamAsync_ParsesManifestAndReturnsStream() {
var manifest = new TidalManifest("audio/flac", "flac", "none", ["http://actual-stream-url"]);
var manifestJson = JsonSerializer.Serialize(manifest);
var manifestBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson));

var playbackInfo = new TidalPlaybackInfo(1, "FULL", "LOSSLESS", "application/vnd.tidal.bt", manifestBase64);

var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);

// First call: GET /tracks/1/playbackinfo
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.ToString().Contains("/tracks/1/playbackinfo")),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage {
StatusCode = HttpStatusCode.OK,
Content = JsonContent.Create(playbackInfo),
});

// Second call: GET http://actual-stream-url
handlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.ToString().Contains("actual-stream-url")),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage {
StatusCode = HttpStatusCode.OK,
Content = new ByteArrayContent([1, 2, 3, 4]),
});

var httpClient = new HttpClient(handlerMock.Object);
var client = TidalStreamingClient.CreateForTesting(httpClient);

var songStream = await client.GetSongStreamAsync("1", TestContext.Current.CancellationToken);

Assert.Equal("1", songStream.Id);
Assert.Equal("flac", songStream.Codec);

var buffer = new byte[4];
await songStream.Stream.ReadAsync(buffer, TestContext.Current.CancellationToken);
Assert.Equal([1, 2, 3, 4], buffer);
}
}
2 changes: 1 addition & 1 deletion smoc/Configuration/StreamingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ public enum StreamingService {
/// Subsonic compatible API.
/// </summary>
Subsonic
}
}
50 changes: 50 additions & 0 deletions smoc/Configuration/TidalConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Terminal.Gui.Configuration;

namespace Smoc.Configuration;

/// <summary>
/// Configuration for Tidal.
/// </summary>
public static class TidalConfig {
/// <summary>
/// Gets or sets the Tidal Client ID.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static string? ClientId { get; set; } = null;

/// <summary>
/// Gets or sets the Tidal Client Secret.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static string? ClientSecret { get; set; } = null;

/// <summary>
/// Gets or sets the Tidal access token.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static string? AccessToken { get; set; } = null;

/// <summary>
/// Gets or sets the Tidal refresh token.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static string? RefreshToken { get; set; } = null;

/// <summary>
/// Gets or sets the Tidal token expiry time.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static DateTime? TokenExpiry { get; set; } = null;

/// <summary>
/// Gets or sets the Tidal country code.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static string CountryCode { get; set; } = "US";

/// <summary>
/// Gets or sets the Tidal audio quality.
/// </summary>
[ConfigurationProperty(Scope = typeof(SettingsScope))]
public static string AudioQuality { get; set; } = "LOSSLESS";
}
11 changes: 11 additions & 0 deletions smoc/Streaming/Tidal/Models/TidalAlbum.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.Tidal.Models;

public record TidalAlbum(
[property: JsonPropertyName("id")] long Id,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("cover")] string? Cover = null,
[property: JsonPropertyName("releaseDate")] string? ReleaseDate = null,
[property: JsonPropertyName("artist")] TidalArtist? Artist = null
);
9 changes: 9 additions & 0 deletions smoc/Streaming/Tidal/Models/TidalArtist.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.Tidal.Models;

public record TidalArtist(
[property: JsonPropertyName("id")] long Id,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("picture")] string? Picture = null
);
12 changes: 12 additions & 0 deletions smoc/Streaming/Tidal/Models/TidalDeviceAuthResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.Tidal.Models;

public record TidalDeviceAuthResponse(
[property: JsonPropertyName("deviceCode")] string DeviceCode,
[property: JsonPropertyName("userCode")] string UserCode,
[property: JsonPropertyName("verificationUri")] string VerificationUri,
[property: JsonPropertyName("verificationUriComplete")] string VerificationUriComplete,
[property: JsonPropertyName("expiresIn")] int ExpiresIn,
[property: JsonPropertyName("interval")] int Interval
);
10 changes: 10 additions & 0 deletions smoc/Streaming/Tidal/Models/TidalManifest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.Tidal.Models;

public record TidalManifest(
[property: JsonPropertyName("mimeType")] string MimeType,
[property: JsonPropertyName("codecs")] string Codecs,
[property: JsonPropertyName("encryptionType")] string EncryptionType,
[property: JsonPropertyName("urls")] List<string> Urls
);
11 changes: 11 additions & 0 deletions smoc/Streaming/Tidal/Models/TidalPlaybackInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.Tidal.Models;

public record TidalPlaybackInfo(
[property: JsonPropertyName("trackId")] long TrackId,
[property: JsonPropertyName("assetPresentation")] string AssetPresentation,
[property: JsonPropertyName("audioQuality")] string AudioQuality,
[property: JsonPropertyName("manifestMimeType")] string ManifestMimeType,
[property: JsonPropertyName("manifest")] string Manifest
);
9 changes: 9 additions & 0 deletions smoc/Streaming/Tidal/Models/TidalPlaylist.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.Tidal.Models;

public record TidalPlaylist(
[property: JsonPropertyName("uuid")] string Uuid,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("description")] string? Description = null
);
10 changes: 10 additions & 0 deletions smoc/Streaming/Tidal/Models/TidalSearchContainer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.Tidal.Models;

public record TidalSearchContainer(
[property: JsonPropertyName("artists")] TidalSearchResponse<TidalArtist>? Artists = null,
[property: JsonPropertyName("albums")] TidalSearchResponse<TidalAlbum>? Albums = null,
[property: JsonPropertyName("tracks")] TidalSearchResponse<TidalTrack>? Tracks = null,
[property: JsonPropertyName("playlists")] TidalSearchResponse<TidalPlaylist>? Playlists = null
);
8 changes: 8 additions & 0 deletions smoc/Streaming/Tidal/Models/TidalSearchResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.Tidal.Models;

public record TidalSearchResponse<T>(
[property: JsonPropertyName("items")] List<T> Items,
[property: JsonPropertyName("totalNumberOfItems")] int TotalNumberOfItems = 0
);
10 changes: 10 additions & 0 deletions smoc/Streaming/Tidal/Models/TidalTokenResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.Tidal.Models;

public record TidalTokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("refresh_token")] string RefreshToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn,
[property: JsonPropertyName("token_type")] string TokenType
);
13 changes: 13 additions & 0 deletions smoc/Streaming/Tidal/Models/TidalTrack.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;

namespace Smoc.Streaming.Tidal.Models;

public record TidalTrack(
[property: JsonPropertyName("id")] long Id,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("duration")] int Duration,
[property: JsonPropertyName("trackNumber")] int TrackNumber,
[property: JsonPropertyName("album")] TidalAlbum Album,
[property: JsonPropertyName("artist")] TidalArtist Artist,
[property: JsonPropertyName("artists")] List<TidalArtist> Artists
);
Loading
Loading