diff --git a/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs b/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs new file mode 100644 index 0000000..a095461 --- /dev/null +++ b/smoc.Tests/Streaming/Tidal/TidalStreamingClientServiceTest.cs @@ -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) CreateClientWithMockResponse(T responseObj, HttpStatusCode statusCode = HttpStatusCode.OK) { + var handlerMock = new Mock(MockBehavior.Strict); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .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([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(MockBehavior.Strict); + + // First call: GET /tracks/1/playbackinfo + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.RequestUri!.ToString().Contains("/tracks/1/playbackinfo")), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(playbackInfo), + }); + + // Second call: GET http://actual-stream-url + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.RequestUri!.ToString().Contains("actual-stream-url")), + ItExpr.IsAny() + ) + .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); + } +} \ No newline at end of file diff --git a/smoc/Configuration/StreamingService.cs b/smoc/Configuration/StreamingService.cs index a427ce2..e905287 100644 --- a/smoc/Configuration/StreamingService.cs +++ b/smoc/Configuration/StreamingService.cs @@ -13,4 +13,4 @@ public enum StreamingService { /// Subsonic compatible API. /// Subsonic -} +} \ No newline at end of file diff --git a/smoc/Configuration/TidalConfig.cs b/smoc/Configuration/TidalConfig.cs new file mode 100644 index 0000000..cece5d9 --- /dev/null +++ b/smoc/Configuration/TidalConfig.cs @@ -0,0 +1,50 @@ +using Terminal.Gui.Configuration; + +namespace Smoc.Configuration; + +/// +/// Configuration for Tidal. +/// +public static class TidalConfig { + /// + /// Gets or sets the Tidal Client ID. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string? ClientId { get; set; } = null; + + /// + /// Gets or sets the Tidal Client Secret. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string? ClientSecret { get; set; } = null; + + /// + /// Gets or sets the Tidal access token. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string? AccessToken { get; set; } = null; + + /// + /// Gets or sets the Tidal refresh token. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string? RefreshToken { get; set; } = null; + + /// + /// Gets or sets the Tidal token expiry time. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static DateTime? TokenExpiry { get; set; } = null; + + /// + /// Gets or sets the Tidal country code. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string CountryCode { get; set; } = "US"; + + /// + /// Gets or sets the Tidal audio quality. + /// + [ConfigurationProperty(Scope = typeof(SettingsScope))] + public static string AudioQuality { get; set; } = "LOSSLESS"; +} \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalAlbum.cs b/smoc/Streaming/Tidal/Models/TidalAlbum.cs new file mode 100644 index 0000000..c33591f --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalAlbum.cs @@ -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 +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalArtist.cs b/smoc/Streaming/Tidal/Models/TidalArtist.cs new file mode 100644 index 0000000..b90d5a8 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalArtist.cs @@ -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 +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalDeviceAuthResponse.cs b/smoc/Streaming/Tidal/Models/TidalDeviceAuthResponse.cs new file mode 100644 index 0000000..5b664de --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalDeviceAuthResponse.cs @@ -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 +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalManifest.cs b/smoc/Streaming/Tidal/Models/TidalManifest.cs new file mode 100644 index 0000000..e2c32f2 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalManifest.cs @@ -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 Urls +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalPlaybackInfo.cs b/smoc/Streaming/Tidal/Models/TidalPlaybackInfo.cs new file mode 100644 index 0000000..ffe263b --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalPlaybackInfo.cs @@ -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 +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalPlaylist.cs b/smoc/Streaming/Tidal/Models/TidalPlaylist.cs new file mode 100644 index 0000000..367b4fd --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalPlaylist.cs @@ -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 +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalSearchContainer.cs b/smoc/Streaming/Tidal/Models/TidalSearchContainer.cs new file mode 100644 index 0000000..8b9fc2b --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalSearchContainer.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalSearchContainer( + [property: JsonPropertyName("artists")] TidalSearchResponse? Artists = null, + [property: JsonPropertyName("albums")] TidalSearchResponse? Albums = null, + [property: JsonPropertyName("tracks")] TidalSearchResponse? Tracks = null, + [property: JsonPropertyName("playlists")] TidalSearchResponse? Playlists = null +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalSearchResponse.cs b/smoc/Streaming/Tidal/Models/TidalSearchResponse.cs new file mode 100644 index 0000000..cf19cf0 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalSearchResponse.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace Smoc.Streaming.Tidal.Models; + +public record TidalSearchResponse( + [property: JsonPropertyName("items")] List Items, + [property: JsonPropertyName("totalNumberOfItems")] int TotalNumberOfItems = 0 +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalTokenResponse.cs b/smoc/Streaming/Tidal/Models/TidalTokenResponse.cs new file mode 100644 index 0000000..29fad06 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalTokenResponse.cs @@ -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 +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/Models/TidalTrack.cs b/smoc/Streaming/Tidal/Models/TidalTrack.cs new file mode 100644 index 0000000..e5d7192 --- /dev/null +++ b/smoc/Streaming/Tidal/Models/TidalTrack.cs @@ -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 Artists +); \ No newline at end of file diff --git a/smoc/Streaming/Tidal/TidalStreamingClient.cs b/smoc/Streaming/Tidal/TidalStreamingClient.cs new file mode 100644 index 0000000..d8bd560 --- /dev/null +++ b/smoc/Streaming/Tidal/TidalStreamingClient.cs @@ -0,0 +1,278 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Smoc.Configuration; +using Smoc.Services; +using Smoc.Services.Caching; +using Smoc.Streaming.Tidal.Models; +using Terminal.Gui.App; + +namespace Smoc.Streaming.Tidal; + +public sealed class TidalStreamingClient : IStreamingClient, IDisposable { + private static readonly string AuthUrl = "https://auth.tidal.com/v1/oauth2"; + private static readonly string ApiUrl = "https://api.tidal.com/v1"; + private readonly HttpClient _httpClient; + private readonly ICacheService _songCacheService; + private readonly ICacheService _albumArtCacheService; + private bool _isDisposed; + + private TidalStreamingClient(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { + _httpClient = new HttpClient(); + _songCacheService = songCacheService ?? new NoCachingCacheService(); + _albumArtCacheService = albumArtCacheService ?? new NoCachingCacheService(); + } + + public static TidalStreamingClient CreateForTesting(HttpClient httpClient, ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { + var client = new TidalStreamingClient(songCacheService, albumArtCacheService); + var httpClientField = typeof(TidalStreamingClient).GetField("_httpClient", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + httpClientField?.SetValue(client, httpClient); + TidalConfig.AccessToken = "test-token"; + return client; + } + + public static TidalStreamingClient Create(ICacheService? songCacheService = null, ICacheService? albumArtCacheService = null) { + return new TidalStreamingClient(songCacheService, albumArtCacheService); + } + + private async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken = default) { + if (string.IsNullOrEmpty(TidalConfig.AccessToken)) { + await AuthorizeDeviceAsync(cancellationToken); + } else if (TidalConfig.TokenExpiry.HasValue && TidalConfig.TokenExpiry.Value < DateTime.UtcNow.AddMinutes(5)) { + await RefreshTokenAsync(cancellationToken); + } + } + + private async Task RefreshTokenAsync(CancellationToken cancellationToken = default) { + if (string.IsNullOrEmpty(TidalConfig.RefreshToken) || string.IsNullOrEmpty(TidalConfig.ClientId)) { + await AuthorizeDeviceAsync(cancellationToken); + return; + } + + Logging.Information("Refreshing Tidal token..."); + var refreshRequest = new Dictionary { + { "client_id", TidalConfig.ClientId }, + { "refresh_token", TidalConfig.RefreshToken }, + { "grant_type", "refresh_token" } + }; + + var response = await _httpClient.PostAsync($"{AuthUrl}/token", new FormUrlEncodedContent(refreshRequest), cancellationToken); + if (response.IsSuccessStatusCode) { + var tokenData = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (tokenData != null) { + TidalConfig.AccessToken = tokenData.AccessToken; + TidalConfig.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.ExpiresIn); + // Refresh token might also be updated + if (!string.IsNullOrEmpty(tokenData.RefreshToken)) { + TidalConfig.RefreshToken = tokenData.RefreshToken; + } + Logging.Information("Tidal token refreshed."); + return; + } + } + + Logging.Warning("Tidal token refresh failed. Re-authorizing device..."); + await AuthorizeDeviceAsync(cancellationToken); + } + + private async Task AuthorizeDeviceAsync(CancellationToken cancellationToken = default) { + if (string.IsNullOrEmpty(TidalConfig.ClientId)) { + throw new InvalidOperationException("Tidal Client ID not configured."); + } + + Logging.Information("Starting Tidal Device Authorization flow..."); + var authRequest = new Dictionary { + { "client_id", TidalConfig.ClientId }, + { "scope", "user" } + }; + + var response = await _httpClient.PostAsync($"{AuthUrl}/device/authorization", new FormUrlEncodedContent(authRequest), cancellationToken); + var authData = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + if (authData == null) throw new InvalidOperationException("Failed to get device authorization data."); + + Logging.Information($"Please visit {authData.VerificationUriComplete} or go to {authData.VerificationUri} and enter code: {authData.UserCode}"); + // In a real CLI, we would display this to the user. For now, we'll log it. + + // Polling logic + var tokenRequest = new Dictionary { + { "client_id", TidalConfig.ClientId }, + { "device_code", authData.DeviceCode }, + { "grant_type", "urn:ietf:params:oauth:grant-type:device_code" } + }; + + while (!cancellationToken.IsCancellationRequested) { + await Task.Delay(authData.Interval * 1000, cancellationToken); + var tokenResponse = await _httpClient.PostAsync($"{AuthUrl}/token", new FormUrlEncodedContent(tokenRequest), cancellationToken); + + if (tokenResponse.IsSuccessStatusCode) { + var tokenData = await tokenResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (tokenData != null) { + TidalConfig.AccessToken = tokenData.AccessToken; + TidalConfig.RefreshToken = tokenData.RefreshToken; + TidalConfig.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.ExpiresIn); + Logging.Information("Tidal authentication successful."); + return; + } + } + + var errorContent = await tokenResponse.Content.ReadAsStringAsync(cancellationToken); + if (!errorContent.Contains("authorization_pending")) { + throw new InvalidOperationException($"Tidal auth failed: {errorContent}"); + } + } + } + + private async Task GetAsync(string endpoint, Dictionary? parameters = null, CancellationToken cancellationToken = default) { + await EnsureAuthenticatedAsync(cancellationToken); + + var url = $"{ApiUrl}/{endpoint.TrimStart('/')}"; + var queryParams = parameters ?? new Dictionary(); + queryParams["countryCode"] = TidalConfig.CountryCode; + + var queryString = string.Join("&", queryParams.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}")); + var fullUrl = $"{url}?{queryString}"; + + var request = new HttpRequestMessage(HttpMethod.Get, fullUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TidalConfig.AccessToken); + + var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { + // Refresh token and retry once + // TODO: Implement refresh logic + } + + return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) ?? throw new InvalidOperationException("API returned null."); + } + + public async Task> SearchArtistsAsync(string query, CancellationToken cancellationToken = default) { + var response = await GetAsync("search", new Dictionary { { "query", query }, { "types", "ARTISTS" }, { "limit", "20" } }, cancellationToken); + return response.Artists?.Items.Select(a => new Artist(a.Id.ToString(), a.Name)).ToList() ?? []; + } + + public async Task> SearchSongsAsync(string query, CancellationToken cancellationToken = default) { + var response = await GetAsync("search", new Dictionary { { "query", query }, { "types", "TRACKS" }, { "limit", "20" } }, cancellationToken); + return response.Tracks?.Items.Select(MapTrackToSong).ToList() ?? []; + } + + public async Task GetSongAsync(string songId, CancellationToken cancellationToken = default) { + var track = await GetAsync($"/tracks/{songId}", null, cancellationToken); + return MapTrackToSong(track); + } + + public async Task GetArtistAsync(string artistId, CancellationToken cancellationToken = default) { + var artist = await GetAsync($"/artists/{artistId}", null, cancellationToken); + return new Artist(artist.Id.ToString(), artist.Name); + } + + public async Task> GetAlbumsByArtistAsync(Artist artist, CancellationToken cancellationToken = default) { + var response = await GetAsync>($"/artists/{artist.Id}/albums", new Dictionary { { "limit", "50" } }, cancellationToken); + return response.Items.Select(a => MapAlbumToAlbum(a, artist)).ToList(); + } + + public async Task> GetSongsByAlbumAsync(Album album, CancellationToken cancellationToken = default) { + var response = await GetAsync>($"/albums/{album.Id}/tracks", new Dictionary { { "limit", "50" } }, cancellationToken); + return response.Items.Select(MapTrackToSong).ToList(); + } + + public async Task GetSongStreamAsync(string songId, CancellationToken cancellationToken = default) { + var playbackInfo = await GetAsync($"/tracks/{songId}/playbackinfo", new Dictionary { + { "audioquality", TidalConfig.AudioQuality }, + { "playbackmode", "STREAM" }, + { "assetpresentation", "FULL" } + }, cancellationToken); + + var manifestBytes = Convert.FromBase64String(playbackInfo.Manifest); + var manifestJson = Encoding.UTF8.GetString(manifestBytes); + var manifest = JsonSerializer.Deserialize(manifestJson); + + if (manifest == null || manifest.Urls.Count == 0) throw new InvalidOperationException("No stream URLs found in manifest."); + + var streamUrl = manifest.Urls[0]; + var stream = await _songCacheService.GetOrAddAsync( + $"{songId}-{playbackInfo.AudioQuality}", + async ct => await _httpClient.GetStreamAsync(streamUrl, ct), + cancellationToken); + + return new SongStream(songId, manifest.MimeType.Contains("flac") ? "flac" : "aac", stream); + } + + public async Task> GetLikedSongsAsync(CancellationToken cancellationToken = default) { + // Requires user authentication and specific scopes + return []; + } + + public async Task> SearchPlaylistsAsync(string query, CancellationToken cancellationToken = default) { + var response = await GetAsync("search", new Dictionary { { "query", query }, { "types", "PLAYLISTS" }, { "limit", "20" } }, cancellationToken); + return response.Playlists?.Items.Select(p => new Playlist(p.Uuid, p.Title)).ToList() ?? []; + } + + public async Task> GetPlaylistSongsAsync(Playlist playlist, CancellationToken cancellationToken = default) { + var response = await GetAsync>($"/playlists/{playlist.Id}/tracks", new Dictionary { { "limit", "50" } }, cancellationToken); + return response.Items.Select(MapTrackToSong).ToList(); + } + + public async Task> GetPlaylistSongsFromUrlAsync(string url, CancellationToken cancellationToken = default) { + // Basic URL parsing for Tidal + if (url.Contains("/track/")) { + var id = url.Split("/track/").Last().Split('/').First(); + return [await GetSongAsync(id, cancellationToken)]; + } + if (url.Contains("/playlist/")) { + var id = url.Split("/playlist/").Last().Split('/').First(); + return await GetPlaylistSongsAsync(new Playlist(id, ""), cancellationToken); + } + return []; + } + + public async Task AddToListenHistory(Song song, CancellationToken cancellationToken = default) { + await Task.CompletedTask; + } + + public async Task> GetAlbumArtAsync(Album album, Func, AlbumCover>? coverSelector = null, CancellationToken cancellationToken = default) { + if (!album.Covers.Any()) + throw new ArgumentException("Album has no covers.", nameof(album)); + + var cover = coverSelector?.Invoke(album.Covers) ?? album.Covers.First(); + + using var albumArt = await _albumArtCacheService.GetOrAddAsync( + string.Concat(album.Id, "-", cover.Width, "x", cover.Height), + async ct => { + var albumResponse = await _httpClient.GetAsync(cover.Url, cancellationToken); + return await albumResponse.Content.ReadAsStreamAsync(cancellationToken); + }, + cancellationToken); + return await Image.LoadAsync(albumArt, cancellationToken); + } + + private Song MapTrackToSong(TidalTrack track) { + var artist = new Artist(track.Artist.Id.ToString(), track.Artist.Name); + var album = MapAlbumToAlbum(track.Album, artist); + return new Song(track.Id.ToString(), album, track.Title, TimeSpan.FromSeconds(track.Duration), track.TrackNumber); + } + + private Album MapAlbumToAlbum(TidalAlbum album, Artist artist) { + var covers = new List(); + if (!string.IsNullOrEmpty(album.Cover)) { + // Tidal covers follow a specific format: https://resources.tidal.com/images/{uuid}/640x640.jpg + var uuid = album.Cover.Replace("-", "/"); + covers.Add(new AlbumCover($"https://resources.tidal.com/images/{uuid}/640x640.jpg", 640, 640)); + covers.Add(new AlbumCover($"https://resources.tidal.com/images/{uuid}/320x320.jpg", 320, 320)); + } + int? releaseYear = null; + if (DateTime.TryParse(album.ReleaseDate, out var date)) { + releaseYear = date.Year; + } + return new Album(album.Id.ToString(), artist, album.Title, covers, releaseYear); + } + + public void Dispose() { + if (_isDisposed) return; + _httpClient.Dispose(); + _isDisposed = true; + } +} \ No newline at end of file